Compare commits

..
837 changed files with 7764 additions and 40039 deletions
-2
View File
@@ -256,8 +256,6 @@ AZURE_APP_SECRET=
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq
# SIDEKIQ_CONCURRENCY=10 # SIDEKIQ_CONCURRENCY=10
# Enable verbose logging each time a job is dequeued in Sidekiq
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
# AI powered features # AI powered features
-1
View File
@@ -70,7 +70,6 @@ jobs:
spec/services/mfa/authentication_service_spec.rb \ spec/services/mfa/authentication_service_spec.rb \
spec/requests/api/v1/profile/mfa_controller_spec.rb \ spec/requests/api/v1/profile/mfa_controller_spec.rb \
spec/controllers/devise_overrides/sessions_controller_spec.rb \ spec/controllers/devise_overrides/sessions_controller_spec.rb \
spec/models/application_record_external_credentials_encryption_spec.rb \
--profile=10 \ --profile=10 \
--format documentation --format documentation
env: env:
+2 -2
View File
@@ -23,7 +23,7 @@ Metrics/MethodLength:
- 'enterprise/lib/captain/agent.rb' - 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 50 Max: 25
Style/Documentation: Style/Documentation:
Enabled: false Enabled: false
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
Enabled: false Enabled: false
FactoryBot/FactoryAssociationWithStrategy: FactoryBot/FactoryAssociationWithStrategy:
Enabled: false Enabled: false
+1 -2
View File
@@ -21,7 +21,6 @@ gem 'telephone_number'
gem 'time_diff' gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
@@ -159,7 +158,7 @@ gem 'working_hours'
gem 'pg_search' gem 'pg_search'
# Subscriptions, Billing # Subscriptions, Billing
gem 'stripe', '17.2.0.pre.alpha.2' gem 'stripe'
## - helper gems --## ## - helper gems --##
## to populate db with sample data ## to populate db with sample data
+6 -8
View File
@@ -270,7 +270,6 @@ GEM
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
http (>= 3.0) http (>= 3.0)
ruby2_keywords ruby2_keywords
email-provider-info (0.0.1)
email_reply_trimmer (0.1.13) email_reply_trimmer (0.1.13)
erubi (1.13.0) erubi (1.13.0)
et-orbi (1.2.11) et-orbi (1.2.11)
@@ -595,7 +594,7 @@ GEM
oj (3.16.10) oj (3.16.10)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
ostruct (>= 0.2) ostruct (>= 0.2)
omniauth (2.1.4) omniauth (2.1.3)
hashie (>= 3.4.6) hashie (>= 3.4.6)
logger logger
rack (>= 2.2.3) rack (>= 2.2.3)
@@ -645,7 +644,7 @@ GEM
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.3) rack (3.2.0)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -654,7 +653,7 @@ GEM
rack (>= 2.0.0) rack (>= 2.0.0)
rack-mini-profiler (3.2.0) rack-mini-profiler (3.2.0)
rack (>= 1.2.0) rack (>= 1.2.0)
rack-protection (4.2.1) rack-protection (4.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0) logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
@@ -902,7 +901,7 @@ GEM
squasher (0.7.2) squasher (0.7.2)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (17.2.0.pre.alpha.2) stripe (8.5.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.4.0) thor (1.4.0)
@@ -936,7 +935,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4) unicode-emoji (4.0.4)
uniform_notifier (1.17.0) uniform_notifier (1.17.0)
uri (1.0.4) uri (1.0.3)
uri_template (0.7.0) uri_template (0.7.0)
valid_email2 (5.2.6) valid_email2 (5.2.6)
activemodel (>= 3.2) activemodel (>= 3.2)
@@ -1017,7 +1016,6 @@ DEPENDENCIES
dotenv-rails (>= 3.0.0) dotenv-rails (>= 3.0.0)
down down
elastic-apm elastic-apm
email-provider-info
email_reply_trimmer email_reply_trimmer
facebook-messenger facebook-messenger
factory_bot_rails (>= 6.4.3) factory_bot_rails (>= 6.4.3)
@@ -1110,7 +1108,7 @@ DEPENDENCIES
spring-watcher-listen spring-watcher-listen
squasher squasher
stackprof stackprof
stripe (= 17.2.0.pre.alpha.2) stripe
telephone_number telephone_number
test-prof test-prof
tidewave tidewave
-4
View File
@@ -36,10 +36,6 @@
"REDIS_OPENSSL_VERIFY_MODE":{ "REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none" "value": "none"
},
"NODE_OPTIONS": {
"description": "Increase V8 heap for Vite build to avoid OOM",
"value": "--max-old-space-size=4096"
} }
}, },
"formation": { "formation": {
+2 -21
View File
@@ -23,7 +23,7 @@ class Messages::MessageBuilder
process_emails process_emails
# When the message has no quoted content, it will just be rendered as a regular message # When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case # The frontend is equipped to handle this case
process_email_content process_email_content if @account.feature_enabled?(:quoted_email_reply)
@message.save! @message.save!
@message @message
end end
@@ -178,13 +178,7 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content) normalized_content = normalize_email_body(@message.content)
# Use custom HTML content if provided, otherwise generate from message content email_attributes[:html_content] = build_html_content(normalized_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[:text_content] = build_text_content(normalized_content)
email_attributes email_attributes
end end
@@ -219,17 +213,4 @@ class Messages::MessageBuilder
ChatwootMarkdownRenderer.new(content).render_message.to_s ChatwootMarkdownRenderer.new(content).render_message.to_s
end 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 end
+16 -22
View File
@@ -10,28 +10,10 @@ class V2::Reports::BaseSummaryBuilder
def load_data def load_data
@conversations_count = fetch_conversations_count @conversations_count = fetch_conversations_count
load_reporting_events_data @resolved_count = fetch_resolved_count
end @avg_resolution_time = fetch_average_time('conversation_resolved')
@avg_first_response_time = fetch_average_time('first_response')
def load_reporting_events_data @avg_reply_time = fetch_average_time('reply_time')
# 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 end
def reporting_events def reporting_events
@@ -42,6 +24,14 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end 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 def group_by_key
# Override this method # Override this method
end end
@@ -50,6 +40,10 @@ class V2::Reports::BaseSummaryBuilder
# Override this method # Override this method
end end
def get_grouped_average(events)
events.group(group_by_key).average(average_value_key)
end
def average_value_key def average_value_key
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
end end
@@ -13,7 +13,10 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
def load_data def load_data
@conversations_count = fetch_conversations_count @conversations_count = fetch_conversations_count
load_reporting_events_data @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')
end end
def fetch_conversations_count def fetch_conversations_count
@@ -1,12 +1,13 @@
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
before_action :type_matches?
def create def create
case normalized_type if type_matches?
when 'Conversation' ::BulkActionsJob.perform_later(
enqueue_conversation_job account: @current_account,
head :ok user: current_user,
when 'Contact' params: permitted_params
check_authorization_for_contact_action )
enqueue_contact_job
head :ok head :ok
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
@@ -15,54 +16,11 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
private private
def normalized_type def type_matches?
params[:type].to_s.camelize ['Conversation'].include?(params[:type])
end end
def enqueue_conversation_job def permitted_params
::BulkActionsJob.perform_later( params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
account: @current_account,
user: current_user,
params: conversation_params
)
end
def enqueue_contact_job
Contacts::BulkActionJob.perform_later(
@current_account.id,
current_user.id,
contact_params
)
end
def delete_contact_action?
params[:action_name] == 'delete'
end
def check_authorization_for_contact_action
authorize(Contact, :destroy?) if delete_contact_action?
end
def conversation_params
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
# and then remove this method in favor of a common params method.
base = params.permit(
:snoozed_until,
fields: [:status, :assignee_id, :team_id]
)
append_common_bulk_attributes(base)
end
def contact_params
# TODO: remove this method in favor of a common params method.
# once legacy conversation payloads are migrated.
append_common_bulk_attributes({})
end
def append_common_bulk_attributes(base_params)
# NOTE: Conversation payloads historically diverged per action. Going forward we
# want all objects to share a common contract: `{ action_name, action_attributes }`
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
base_params.merge(common)
end end
end end
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
end end
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) @conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
def inbox def inbox
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
private private
def authorize_request def authorize_request
authorize @conversation, :show? authorize @conversation.inbox, :show?
end end
def render_response(response) def render_response(response)
@@ -14,7 +14,6 @@ module AccessTokenAuthHelper
ensure_access_token ensure_access_token
render_unauthorized('Invalid Access Token') && return if @access_token.blank? 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 @resource = @access_token.owner
Current.user = @resource if allowed_current_user_type?(@resource) Current.user = @resource if allowed_current_user_type?(@resource)
end end
@@ -19,19 +19,6 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token) redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
end 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 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: 'no-account-found') unless account_signup_allowed?
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
+2 -1
View File
@@ -156,7 +156,8 @@ class ConversationFinder
def filter_by_labels def filter_by_labels
return unless params[:labels] return unless params[:labels]
@conversations = @conversations.tagged_with(params[:labels], any: true) pattern = params[:labels].map { |label| Regexp.escape(label) }.join('|')
@conversations = @conversations.where('cached_label_list ~ ?', "\\y(#{pattern})\\y")
end end
def filter_by_source_id def filter_by_source_id
+1 -4
View File
@@ -8,10 +8,7 @@ module BillingHelper
# Return false if not plans are configured, so that no checks are enforced # Return false if not plans are configured, so that no checks are enforced
return false if default_plan.blank? return false if default_plan.blank?
# Handle both string and hash formats for default_plan account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan['name']
default_plan_name = default_plan.is_a?(Hash) ? default_plan['name'] : default_plan
account.custom_attributes['plan_name'].nil? || account.custom_attributes['plan_name'] == default_plan_name
end end
def conversations_this_month(account) def conversations_this_month(account)
+13
View File
@@ -1,5 +1,6 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
@@ -24,6 +25,7 @@ export default {
name: 'App', name: 'App',
components: { components: {
AddAccountModal,
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
@@ -49,6 +51,7 @@ export default {
}, },
data() { data() {
return { return {
showAddAccountModal: false,
latestChatwootVersion: null, latestChatwootVersion: null,
reconnectService: null, reconnectService: null,
}; };
@@ -61,12 +64,21 @@ export default {
authUIFlags: 'getAuthUIFlags', authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags', accountUIFlags: 'accounts/getUIFlags',
}), }),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
hideOnOnboardingView() { hideOnOnboardingView() {
return !isOnOnboardingView(this.$route); return !isOnOnboardingView(this.$route);
}, },
}, },
watch: { watch: {
currentUser() {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
},
currentAccountId: { currentAccountId: {
immediate: true, immediate: true,
handler() { handler() {
@@ -144,6 +156,7 @@ export default {
<component :is="Component" /> <component :is="Component" />
</transition> </transition>
</router-view> </router-view>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox /> <WootSnackbarBox />
<NetworkNotification /> <NetworkNotification />
</div> </div>
-16
View File
@@ -1,16 +0,0 @@
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();
@@ -5,7 +5,6 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({ defineProps({
selectedContact: { selectedContact: {
@@ -25,44 +24,42 @@ const openConfirmDeleteContactDialog = () => {
</script> </script>
<template> <template>
<Policy :permissions="['administrator']"> <div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5"> <Button
<Button :label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')" sm
sm link
link slate
slate class="hover:!no-underline text-n-slate-12"
class="hover:!no-underline text-n-slate-12" icon="i-lucide-chevron-down"
icon="i-lucide-chevron-down" trailing-icon
trailing-icon @click="toggleDeleteSection()"
@click="toggleDeleteSection()" />
/>
<div <div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden" class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class=" :class="
showDeleteSection showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2' ? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0' : 'grid-rows-[0fr] opacity-0 mt-0'
" "
> >
<div class="overflow-hidden min-h-0"> <div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1"> <span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }} {{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button <Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')" :label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm sm
ruby ruby
link link
@click="openConfirmDeleteContactDialog()" @click="openConfirmDeleteContactDialog()"
/> />
</span> </span>
</div>
</div> </div>
</div> </div>
<ConfirmContactDeleteDialog </div>
ref="confirmDeleteContactDialogRef" <ConfirmContactDeleteDialog
:selected-contact="selectedContact" ref="confirmDeleteContactDialogRef"
/> :selected-contact="selectedContact"
</Policy> />
</template> </template>
@@ -8,7 +8,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue';
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import countries from 'shared/constants/countries'; import countries from 'shared/constants/countries';
const props = defineProps({ const props = defineProps({
@@ -21,17 +20,9 @@ const props = defineProps({
availabilityStatus: { type: String, default: null }, availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false }, isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false },
selectable: { type: Boolean, default: false },
isSelected: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
'toggle',
'updateContact',
'showContact',
'select',
'avatarHover',
]);
const { t } = useI18n(); const { t } = useI18n();
@@ -97,148 +88,111 @@ const onClickExpand = () => {
}; };
const onClickViewDetails = () => emit('showContact', props.id); const onClickViewDetails = () => emit('showContact', props.id);
const toggleSelect = checked => {
emit('select', checked);
};
const handleAvatarHover = isHovered => {
emit('avatarHover', isHovered);
};
</script> </script>
<template> <template>
<div class="relative"> <CardLayout :key="id" layout="row">
<CardLayout <div class="flex items-center justify-start flex-1 gap-4">
:key="id" <Avatar
layout="row" :name="name"
:class="{ :src="thumbnail"
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected, :size="48"
}" :status="availabilityStatus"
> hide-offline-status
<div class="flex items-center justify-start flex-1 gap-4"> rounded-full
<div />
class="relative" <div class="flex flex-col gap-0.5 flex-1">
@mouseenter="handleAvatarHover(true)" <div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@mouseleave="handleAvatarHover(false)" <span class="text-base font-medium truncate text-n-slate-12">
> {{ name }}
<Avatar </span>
:name="name" <span class="inline-flex items-center gap-1">
:src="thumbnail"
:size="48"
:status="availabilityStatus"
hide-offline-status
rounded-full
>
<template v-if="selectable" #overlay="{ size }">
<label
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
:style="{ width: `${size}px`, height: `${size}px` }"
@click.stop
>
<Checkbox
:model-value="isSelected"
@change="event => toggleSelect(event.target.checked)"
/>
</label>
</template>
</Avatar>
</div>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
{{ name }}
</span>
<span class="inline-flex items-center gap-1">
<span
v-if="additionalAttributes?.companyName"
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
/>
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
>
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span <span
v-if="countryDetails" v-if="additionalAttributes?.companyName"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11" class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/> />
<span
v-if="additionalAttributes?.companyName"
class="text-sm truncate text-n-slate-11"
>
{{ additionalAttributes.companyName }}
</span>
</span>
</div>
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
<div v-if="email" class="truncate max-w-72" :title="email">
<span class="text-sm text-n-slate-11">
{{ email }}
</span>
</div> </div>
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
variant="link"
size="xs"
@click="onClickViewDetails"
/>
</div> </div>
</div> </div>
</div>
<Button <Button
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
variant="ghost" variant="ghost"
color="slate" color="slate"
size="xs" size="xs"
:class="{ 'rotate-180': isExpanded }" :class="{ 'rotate-180': isExpanded }"
@click="onClickExpand" @click="onClickExpand"
/> />
<template #after> <template #after>
<div <div
class="transition-all duration-500 ease-in-out grid overflow-hidden" class="transition-all duration-500 ease-in-out grid overflow-hidden"
:class=" :class="
isExpanded isExpanded
? 'grid-rows-[1fr] opacity-100' ? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0' : 'grid-rows-[0fr] opacity-0'
" "
> >
<div class="overflow-hidden"> <div class="overflow-hidden">
<div class="flex flex-col gap-6 p-6 border-t border-n-strong"> <div class="flex flex-col gap-6 p-6 border-t border-n-strong">
<ContactsForm <ContactsForm
ref="contactsFormRef" ref="contactsFormRef"
:contact-data="contactData" :contact-data="contactData"
@update="handleFormUpdate" @update="handleFormUpdate"
/>
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/> />
<div>
<Button
:label="
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid"
@click="handleUpdateContact"
/>
</div>
</div> </div>
<ContactDeleteSection
:selected-contact="{
id: props.id,
name: props.name,
}"
/>
</div> </div>
</template> </div>
</CardLayout> </template>
</div> </CardLayout>
</template> </template>
@@ -10,7 +10,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({ const props = defineProps({
selectedContact: { selectedContact: {
@@ -175,29 +174,27 @@ const handleAvatarDelete = async () => {
@click="updateContact" @click="updateContact"
/> />
</div> </div>
<Policy :permissions="['administrator']"> <div
<div class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong" >
> <div class="flex flex-col gap-2">
<div class="flex flex-col gap-2"> <h6 class="text-base font-medium text-n-slate-12">
<h6 class="text-base font-medium text-n-slate-12"> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} </h6>
</h6> <span class="text-sm text-n-slate-11">
<span class="text-sm text-n-slate-11"> {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} </span>
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div> </div>
<ConfirmContactDeleteDialog <Button
ref="confirmDeleteContactDialogRef" :label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
:selected-contact="selectedContact" color="ruby"
@go-to-contacts-list="emit('goToContactsList')" @click="openConfirmDeleteContactDialog"
/> />
</Policy> </div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div> </div>
</template> </template>
@@ -10,15 +10,7 @@ import {
} from 'shared/helpers/CustomErrors'; } from 'shared/helpers/CustomErrors';
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
const props = defineProps({ defineProps({ contacts: { type: Array, required: true } });
contacts: { type: Array, required: true },
selectedContactIds: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['toggleContact']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -28,9 +20,6 @@ const route = useRoute();
const uiFlags = useMapGetter('contacts/getUIFlags'); const uiFlags = useMapGetter('contacts/getUIFlags');
const isUpdating = computed(() => uiFlags.value.isUpdating); const isUpdating = computed(() => uiFlags.value.isUpdating);
const expandedCardId = ref(null); const expandedCardId = ref(null);
const hoveredAvatarId = ref(null);
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
const updateContact = async updatedData => { const updateContact = async updatedData => {
try { try {
@@ -69,43 +58,25 @@ const onClickViewDetails = async id => {
const toggleExpanded = id => { const toggleExpanded = id => {
expandedCardId.value = expandedCardId.value === id ? null : id; expandedCardId.value = expandedCardId.value === id ? null : id;
}; };
const isSelected = id => selectedIdsSet.value.has(id);
const shouldShowSelection = id => {
return hoveredAvatarId.value === id || isSelected(id);
};
const handleSelect = (id, value) => {
emit('toggleContact', { id, value });
};
const handleAvatarHover = (id, isHovered) => {
hoveredAvatarId.value = isHovered ? id : null;
};
</script> </script>
<template> <template>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4 px-6 pt-4 pb-6">
<div v-for="contact in contacts" :key="contact.id" class="relative"> <ContactsCard
<ContactsCard v-for="contact in contacts"
:id="contact.id" :id="contact.id"
:name="contact.name" :key="contact.id"
:email="contact.email" :name="contact.name"
:thumbnail="contact.thumbnail" :email="contact.email"
:phone-number="contact.phoneNumber" :thumbnail="contact.thumbnail"
:additional-attributes="contact.additionalAttributes" :phone-number="contact.phoneNumber"
:availability-status="contact.availabilityStatus" :additional-attributes="contact.additionalAttributes"
:is-expanded="expandedCardId === contact.id" :availability-status="contact.availabilityStatus"
:is-updating="isUpdating" :is-expanded="expandedCardId === contact.id"
:selectable="shouldShowSelection(contact.id)" :is-updating="isUpdating"
:is-selected="isSelected(contact.id)" @toggle="toggleExpanded(contact.id)"
@toggle="toggleExpanded(contact.id)" @update-contact="updateContact"
@update-contact="updateContact" @show-contact="onClickViewDetails"
@show-contact="onClickViewDetails" />
@select="value => handleSelect(contact.id, value)"
@avatar-hover="value => handleAvatarHover(contact.id, value)"
/>
</div>
</div> </div>
</template> </template>
@@ -1,6 +1,5 @@
<script setup> <script setup>
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed } from 'vue'; import { computed } from 'vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -56,17 +55,17 @@ useKeyboardEvents(keyboardEvents);
</script> </script>
<template> <template>
<ButtonGroup <div
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow" class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
> >
<Button <Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')" v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
ghost ghost
slate slate
sm sm
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75" class="!rounded-full"
:class="{ :class="{
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen, 'bg-n-alpha-2': isContactSidebarOpen,
}" }"
icon="i-ph-user-bold" icon="i-ph-user-bold"
@click="handleConversationSidebarToggle" @click="handleConversationSidebarToggle"
@@ -76,14 +75,13 @@ useKeyboardEvents(keyboardEvents);
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')" v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
ghost ghost
slate slate
sm class="!rounded-full"
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
:class="{ :class="{
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm': 'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
isCopilotPanelOpen,
}" }"
sm
icon="i-woot-captain" icon="i-woot-captain"
@click="handleCopilotSidebarToggle" @click="handleCopilotSidebarToggle"
/> />
</ButtonGroup> </div>
</template> </template>
@@ -21,10 +21,6 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] }, enabledMenuOptions: { type: Array, default: () => [] },
enableCaptainTools: { type: Boolean, default: false }, enableCaptainTools: { type: Boolean, default: false },
signature: { type: String, default: '' },
allowSignature: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
channelType: { type: String, default: '' },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -104,10 +100,6 @@ watch(
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions" :enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools" :enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -14,10 +14,6 @@ defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
showBackdrop: {
type: Boolean,
default: true,
},
}); });
</script> </script>
@@ -29,24 +25,14 @@ defineProps({
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
> >
<div <div
v-if="showBackdrop"
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none" class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
> >
<slot name="empty-state-item" /> <slot name="empty-state-item" />
</div> </div>
<div <div
class="flex flex-col items-center justify-end w-full h-full pb-20" class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
> >
<div <div class="flex flex-col items-center justify-center gap-6">
class="flex flex-col items-center justify-center gap-6"
:class="{
'mt-48': !showBackdrop,
}"
>
<div class="flex flex-col items-center justify-center gap-3"> <div class="flex flex-col items-center justify-center gap-3">
<h2 <h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay" class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
@@ -54,7 +40,6 @@ defineProps({
{{ title }} {{ title }}
</h2> </h2>
<p <p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]" class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
> >
{{ subtitle }} {{ subtitle }}
@@ -14,7 +14,6 @@ import {
} from 'dashboard/helper/portalHelper'; } from 'dashboard/helper/portalHelper';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -141,12 +140,11 @@ const updateArticleStatus = async ({ value }) => {
:disabled="!articleId" :disabled="!articleId"
@click="previewArticle" @click="previewArticle"
/> />
<ButtonGroup class="flex items-center"> <div class="flex items-center">
<Button <Button
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')" :label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
size="sm" size="sm"
class="ltr:rounded-r-none rtl:rounded-l-none" class="ltr:rounded-r-none rtl:rounded-l-none"
no-animation
:is-loading="isArticlePublishing" :is-loading="isArticlePublishing"
:disabled=" :disabled="
status === ARTICLE_STATUSES.PUBLISHED || status === ARTICLE_STATUSES.PUBLISHED ||
@@ -161,7 +159,6 @@ const updateArticleStatus = async ({ value }) => {
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
size="sm" size="sm"
:disabled="!articleId" :disabled="!articleId"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none" class="ltr:rounded-l-none rtl:rounded-r-none"
@click.stop="showArticleActionMenu = !showArticleActionMenu" @click.stop="showArticleActionMenu = !showArticleActionMenu"
/> />
@@ -173,7 +170,7 @@ const updateArticleStatus = async ({ value }) => {
/> />
</OnClickOutside> </OnClickOutside>
</div> </div>
</ButtonGroup> </div>
</div> </div>
</div> </div>
</div> </div>
@@ -92,6 +92,7 @@ const setSignature = () => {
const toggleMessageSignature = () => { const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
}; };
// Added this watch to dynamically set signature on target inbox change. // Added this watch to dynamically set signature on target inbox change.
@@ -199,20 +199,16 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = []; state.attachedFiles = [];
}; };
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => { const removeTargetInbox = value => {
v$.value.$reset(); v$.value.$reset();
removeSignatureFromMessage(); // Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
state.message = removeSignature(state.message, signatureToRemove);
}
emit('updateTargetInbox', value); emit('updateTargetInbox', value);
state.attachedFiles = []; state.attachedFiles = [];
}; };
@@ -220,7 +216,6 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => { const clearSelectedContact = () => {
emit('clearSelectedContact'); emit('clearSelectedContact');
state.attachedFiles = []; state.attachedFiles = [];
removeSignatureFromMessage();
}; };
const onClickInsertEmoji = emoji => { const onClickInsertEmoji = emoji => {
@@ -359,7 +354,6 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid" :has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0" :has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/> />
<AttachmentPreviews <AttachmentPreviews
@@ -17,7 +17,6 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false }, hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false }, sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' }, messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -91,10 +90,6 @@ const replaceText = async message => {
" "
enable-variables enable-variables
:show-character-count="false" :show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/> />
</template> </template>
<template v-else> <template v-else>
@@ -36,7 +36,6 @@ const props = defineProps({
icon: { type: [String, Object, Function], default: '' }, icon: { type: [String, Object, Function], default: '' },
trailingIcon: { type: Boolean, default: false }, trailingIcon: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false }, isLoading: { type: Boolean, default: false },
noAnimation: { type: Boolean, default: false },
}); });
const slots = useSlots(); const slots = useSlots();
@@ -180,18 +179,12 @@ const STYLE_CONFIG = {
md: 'text-sm font-medium', md: 'text-sm font-medium',
lg: 'text-base', lg: 'text-base',
}, },
clickAnimation: {
xs: 'active:enabled:scale-[0.97]',
sm: 'active:enabled:scale-[0.97]',
md: 'active:enabled:scale-[0.98]',
lg: 'active:enabled:scale-[0.98]',
},
justify: { justify: {
start: 'justify-start', start: 'justify-start',
center: 'justify-center', center: 'justify-center',
end: 'justify-end', end: 'justify-end',
}, },
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50', base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
}; };
const variantClasses = computed(() => { const variantClasses = computed(() => {
@@ -228,12 +221,6 @@ const linkButtonClasses = computed(() => {
return classes.join(' '); return classes.join(' ');
}); });
const animationClasses = computed(() => {
return props.noAnimation
? ''
: STYLE_CONFIG.clickAnimation[computedSize.value];
});
</script> </script>
<template> <template>
@@ -243,7 +230,6 @@ const animationClasses = computed(() => {
[STYLE_CONFIG.base]: true, [STYLE_CONFIG.base]: true,
[isLink ? linkButtonClasses : buttonClasses]: true, [isLink ? linkButtonClasses : buttonClasses]: true,
[STYLE_CONFIG.fontSize[computedSize]]: true, [STYLE_CONFIG.fontSize[computedSize]]: true,
[animationClasses]: true,
[STYLE_CONFIG.justify[computedJustify]]: true, [STYLE_CONFIG.justify[computedJustify]]: true,
'flex-row-reverse': trailingIcon && !isIconOnly, 'flex-row-reverse': trailingIcon && !isIconOnly,
}" }"
@@ -1,20 +0,0 @@
<script setup>
defineProps({
noAnimation: {
type: Boolean,
default: false,
},
});
</script>
<template>
<div
:class="
noAnimation
? ''
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
"
>
<slot />
</div>
</template>
@@ -1,17 +1,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { computed } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue'; import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
const props = defineProps({ const props = defineProps({
currentPage: { currentPage: {
@@ -62,32 +56,11 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showAssistantSwitcher: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['click', 'close', 'update:currentPage']); const emit = defineEmits(['click', 'close', 'update:currentPage']);
const route = useRoute();
const { shouldShowPaywall } = usePolicy(); const { shouldShowPaywall } = usePolicy();
const showAssistantSwitcherDropdown = ref(false);
const createAssistantDialogRef = ref(null);
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const activeAssistantName = computed(() => {
return (
assistants.value?.find(
assistant => assistant.id === Number(currentAssistantId.value)
)?.name || ''
);
});
const showPaywall = computed(() => { const showPaywall = computed(() => {
return shouldShowPaywall(props.featureFlag); return shouldShowPaywall(props.featureFlag);
}); });
@@ -99,15 +72,6 @@ const handleButtonClick = () => {
const handlePageChange = event => { const handlePageChange = event => {
emit('update:currentPage', event); emit('update:currentPage', event);
}; };
const toggleAssistantSwitcher = () => {
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
};
const handleCreateAssistant = () => {
showAssistantSwitcherDropdown.value = false;
createAssistantDialogRef.value.dialogRef.open();
};
</script> </script>
<template> <template>
@@ -118,48 +82,9 @@ const handleCreateAssistant = () => {
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
> >
<div class="flex gap-4 items-center"> <div class="flex gap-4 items-center">
<BackButton v-if="backUrl" :back-url="backUrl" /> <BackButton v-if="backUrl" :to="backUrl" />
<slot name="headerTitle"> <slot name="headerTitle">
<div v-if="showAssistantSwitcher" class="flex items-center gap-2"> <span class="text-xl font-medium text-n-slate-12">
<div class="flex items-center gap-1">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
<Icon
v-if="activeAssistantName"
icon="i-lucide-chevron-right"
class="size-6 text-n-slate-11"
/>
<span class="text-xl font-medium text-n-slate-11">
{{ headerTitle }}
</span>
</div>
</div>
<span v-else class="text-xl font-medium text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
</slot> </slot>
@@ -172,27 +97,23 @@ const handleCreateAssistant = () => {
</div> </div>
</div> </div>
<div class="flex gap-2"> <div
<slot name="search" /> v-if="!showPaywall && buttonLabel"
<div v-on-clickaway="() => emit('close')"
v-if="!showPaywall && buttonLabel" class="relative group/campaign-button"
v-on-clickaway="() => emit('close')" >
class="relative group/captain-button" <Policy :permissions="buttonPolicy">
> <Button
<Policy :permissions="buttonPolicy"> :label="buttonLabel"
<Button icon="i-lucide-plus"
:label="buttonLabel" size="sm"
icon="i-lucide-plus" class="group-hover/campaign-button:brightness-110"
size="sm" @click="handleButtonClick"
class="group-hover/captain-button:brightness-110" />
@click="handleButtonClick" </Policy>
/> <slot name="action" />
</Policy>
<slot name="action" />
</div>
</div> </div>
</div> </div>
<slot name="subHeader" />
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
@@ -222,6 +143,5 @@ const handleCreateAssistant = () => {
@update:current-page="handlePageChange" @update:current-page="handlePageChange"
/> />
</footer> </footer>
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
</section> </section>
</template> </template>
@@ -0,0 +1,91 @@
<script setup>
import { useRouter } from 'vue-router';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
defineProps({
isFetching: {
type: Boolean,
default: false,
},
isEmpty: {
type: Boolean,
default: false,
},
currentPage: {
type: Number,
default: 1,
},
totalCount: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 25,
},
showPaginationFooter: {
type: Boolean,
default: false,
},
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:currentPage']);
const router = useRouter();
const handlePageChange = event => {
emit('update:currentPage', event);
};
const handleBreadcrumbClick = item => {
router.push({
name: item.routeName,
});
};
</script>
<template>
<section
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
>
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
</header>
<main class="flex gap-16 w-full flex-1 pb-16">
<section
v-if="$slots.body || $slots.emptyState || isFetching"
class="flex flex-col w-full"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else-if="isEmpty">
<slot name="emptyState" />
</div>
<slot v-else name="body" />
</section>
<section v-if="$slots.controls" class="flex w-full">
<slot name="controls" />
</section>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
@update:current-page="handlePageChange"
/>
</footer>
</div>
</section>
</template>
@@ -76,11 +76,12 @@ const handleAction = ({ action, value }) => {
<template> <template>
<CardLayout> <CardLayout>
<div class="flex justify-between w-full gap-1"> <div class="flex justify-between w-full gap-1">
<h6 <router-link
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors" :to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
> >
{{ name }} {{ name }}
</h6> </router-link>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import MessageList from './MessageList.vue'; import MessageList from './MessageList.vue';
@@ -29,16 +29,6 @@ const resetConversation = () => {
newMessage.value = ''; newMessage.value = '';
}; };
// Watch for assistant ID changes and reset conversation
watch(
() => assistantId,
(newId, oldId) => {
if (oldId && newId !== oldId) {
resetConversation();
}
}
);
const sendMessage = async () => { const sendMessage = async () => {
if (!newMessage.value.trim() || isLoading.value) return; if (!newMessage.value.trim() || isLoading.value) return;
@@ -84,7 +74,7 @@ const sendMessage = async () => {
</h3> </h3>
<NextButton <NextButton
ghost ghost
sm size="small"
icon="i-lucide-rotate-ccw" icon="i-lucide-rotate-ccw"
@click="resetConversation" @click="resetConversation"
/> />
@@ -107,7 +97,7 @@ const sendMessage = async () => {
/> />
<NextButton <NextButton
ghost ghost
sm size="small"
:disabled="!newMessage.trim()" :disabled="!newMessage.trim()"
icon="i-lucide-send" icon="i-lucide-send"
@click="sendMessage" @click="sendMessage"
@@ -64,23 +64,20 @@ const bulkCheckboxState = computed({
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow" class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div class="flex items-center gap-1.5 min-w-0"> <div class="flex items-center gap-1.5">
<Checkbox <Checkbox
v-model="bulkCheckboxState" v-model="bulkCheckboxState"
:indeterminate="isIndeterminate" :indeterminate="isIndeterminate"
/> />
<span <span class="text-sm font-medium text-n-slate-12 tabular-nums">
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
>
{{ selectAllLabel }} {{ selectAllLabel }}
</span> </span>
</div> </div>
<span class="text-sm text-n-slate-10 truncate tabular-nums"> <span class="text-sm text-n-slate-10 tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div class="h-4 w-px bg-n-strong" />
<slot name="secondary-actions" />
</div> </div>
<div class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button
@@ -9,7 +9,6 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue'; import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -60,10 +59,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showActions: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['action', 'navigate', 'select', 'hover']); const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
@@ -164,116 +159,73 @@ const handleDocumentableClick = () => {
<span class="text-n-slate-11 text-sm line-clamp-5"> <span class="text-n-slate-11 text-sm line-clamp-5">
{{ answer }} {{ answer }}
</span> </span>
<div <div v-if="!compact" class="items-center justify-between hidden lg:flex">
v-if="!compact" <div class="inline-flex items-center">
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3" <span
> class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
<Policy v-if="showActions" :permissions="['administrator']"> >
<div class="flex items-center gap-2 sm:gap-5 w-full"> <i class="i-woot-captain" />
<Button {{ assistant?.name || '' }}
v-if="status === 'pending'" </span>
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')" <div
icon="i-lucide-circle-check-big" v-if="documentable"
sm class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
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 <span
v-if="status === 'approved'" v-if="documentable.type === 'Captain::Document'"
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1" class="inline-flex items-center gap-1 truncate over"
> >
<Icon icon="i-woot-captain" class="size-3.5" /> <i class="i-ph-files-light text-base" />
{{ assistant?.name || '' }} <span class="max-w-96 truncate" :title="documentable.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 }} {{ documentable.name }}
</span> </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 <span
v-else-if="documentable.type === 'User'" class="max-w-96 truncate"
class="truncate"
:title="documentable.available_name" :title="documentable.available_name"
> >
{{ documentable.available_name }} {{ documentable.available_name }}
</span> </span>
<span </span>
v-else-if="documentable.type === 'Conversation'" <span
class="hover:underline truncate cursor-pointer" v-else-if="documentable.type === 'Conversation'"
role="button" class="inline-flex items-center gap-1 group cursor-pointer"
@click="handleDocumentableClick" role="button"
> @click="handleDocumentableClick"
>
<i class="i-ph-chat-circle-dots text-base" />
<span class="group-hover:underline">
{{ {{
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, { t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
id: documentable.display_id, id: documentable.display_id,
}) })
}} }}
</span> </span>
</div> </span>
<span v-else />
</div> </div>
<div <div
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1" v-if="status !== 'approved'"
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
> >
<Icon icon="i-ph-calendar-dot" class="size-3.5" /> <i
{{ timestamp }} class="i-ph-stack text-base"
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
/>
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
</div> </div>
</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> </div>
</CardLayout> </CardLayout>
</template> </template>
@@ -0,0 +1,68 @@
<script setup>
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
assistantId: {
type: [String, Number],
required: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const isFilterOpen = ref(false);
const assistants = useMapGetter('captainAssistants/getRecords');
const assistantOptions = computed(() => [
{
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
value: 'all',
action: 'filter',
},
...assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
action: 'filter',
})),
]);
const selectedAssistantLabel = computed(() => {
const assistant = assistantOptions.value.find(
option => option.value === props.assistantId
);
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
selected: assistant ? assistant.label : '',
});
});
const handleAssistantFilterChange = ({ value }) => {
isFilterOpen.value = false;
emit('update', value);
};
</script>
<template>
<OnClickOutside @trigger="isFilterOpen = false">
<Button
:label="selectedAssistantLabel"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isFilterOpen = !isFilterOpen"
/>
<DropdownMenu
v-if="isFilterOpen"
:menu-items="assistantOptions"
class="mt-2"
@action="handleAssistantFilterChange"
/>
</OnClickOutside>
</template>
@@ -18,7 +18,7 @@ const props = defineProps({
validator: value => ['create', 'edit'].includes(value), validator: value => ['create', 'edit'].includes(value),
}, },
}); });
const emit = defineEmits(['close', 'created']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -35,18 +35,8 @@ const i18nKey = computed(
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}` () => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
); );
const createAssistant = async assistantDetails => { const createAssistant = assistantDetails =>
try { store.dispatch('captainAssistants/create', assistantDetails);
const newAssistant = await store.dispatch(
'captainAssistants/create',
assistantDetails
);
emit('created', newAssistant);
} catch (error) {
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleSubmit = async updatedAssistant => { const handleSubmit = async updatedAssistant => {
try { try {
@@ -0,0 +1,333 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
assistant: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
};
const initialState = {
name: '',
description: '',
productName: '',
welcomeMessage: '',
handoffMessage: '',
resolutionMessage: '',
instructions: '',
features: {
conversationFaqs: false,
memories: false,
citations: false,
},
temperature: 1,
};
const state = reactive({ ...initialState });
const validationRules = {
name: { required, minLength: minLength(1) },
description: { required, minLength: minLength(1) },
productName: { required, minLength: minLength(1) },
welcomeMessage: { minLength: minLength(1) },
handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
name: getErrorMessage('name'),
description: getErrorMessage('description'),
productName: getErrorMessage('productName'),
welcomeMessage: getErrorMessage('welcomeMessage'),
handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
}));
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.welcomeMessage = config.welcome_message;
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.features = {
conversationFaqs: config.feature_faq || false,
memories: config.feature_memory || false,
citations: config.feature_citation || false,
};
state.temperature = config.temperature || 1;
};
const handleBasicInfoUpdate = async () => {
const result = await Promise.all([
v$.value.name.$validate(),
v$.value.description.$validate(),
v$.value.productName.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
name: state.name,
description: state.description,
config: {
...props.assistant.config,
product_name: state.productName,
},
};
emit('submit', payload);
};
const handleSystemMessagesUpdate = async () => {
const result = await Promise.all([
v$.value.welcomeMessage.$validate(),
v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(),
]).then(results => results.every(Boolean));
if (!result) return;
const payload = {
config: {
...props.assistant.config,
welcome_message: state.welcomeMessage,
handoff_message: state.handoffMessage,
resolution_message: state.resolutionMessage,
},
};
emit('submit', payload);
};
const handleInstructionsUpdate = async () => {
const result = await v$.value.instructions.$validate();
if (!result) return;
const payload = {
config: {
...props.assistant.config,
temperature: state.temperature || 1,
instructions: state.instructions,
},
};
emit('submit', payload);
};
const handleFeaturesUpdate = () => {
const payload = {
config: {
...props.assistant.config,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
},
};
emit('submit', payload);
};
watch(
() => props.assistant,
newAssistant => {
if (props.mode === 'edit' && newAssistant) {
updateStateFromAssistant(newAssistant);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Information Section -->
<Accordion
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
is-open
>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.name"
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
:message="formErrors.name"
:message-type="formErrors.name ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
:message="formErrors.description"
:message-type="formErrors.description ? 'error' : 'info'"
/>
<Input
v-model="state.productName"
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
:message="formErrors.productName"
:message-type="formErrors.productName ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicInfoUpdate"
>
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Instructions Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
<div class="flex flex-col gap-4">
<Editor
v-model="state.instructions"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2 mt-4">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
</label>
<div class="flex items-center gap-4">
<input
v-model="state.temperature"
type="range"
min="0"
max="1"
step="0.1"
class="w-full"
/>
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
</div>
<p class="text-sm text-n-slate-11 italic">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
</p>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleInstructionsUpdate"
/>
</div>
</div>
</Accordion>
<!-- Greeting Messages Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
<div class="flex flex-col gap-4 pt-4">
<Editor
v-model="state.handoffMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
"
:message="formErrors.handoffMessage"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
/>
<Editor
v-model="state.resolutionMessage"
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
:placeholder="
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
"
:message="formErrors.resolutionMessage"
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleSystemMessagesUpdate"
/>
</div>
</div>
</Accordion>
<!-- Features Section -->
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
</label>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
<input
v-model="state.features.conversationFaqs"
type="checkbox"
/>
{{
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
}}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.memories" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
</label>
<label class="flex items-center gap-2">
<input v-model="state.features.citations" type="checkbox" />
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
</label>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
@click="handleFeaturesUpdate"
/>
</div>
</div>
</Accordion>
</form>
</template>
@@ -3,8 +3,6 @@ import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minLength } from '@vuelidate/validators'; import { minLength } from '@vuelidate/validators';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
@@ -19,16 +17,10 @@ const props = defineProps({
const emit = defineEmits(['submit']); const emit = defineEmits(['submit']);
const { t } = useI18n(); const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const initialState = { const initialState = {
handoffMessage: '', handoffMessage: '',
resolutionMessage: '', resolutionMessage: '',
instructions: '',
temperature: 1, temperature: 1,
}; };
@@ -37,7 +29,6 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
handoffMessage: { minLength: minLength(1) }, handoffMessage: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) }, resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
}; };
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
@@ -49,30 +40,20 @@ const getErrorMessage = field => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'), handoffMessage: getErrorMessage('handoffMessage'),
resolutionMessage: getErrorMessage('resolutionMessage'), resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
})); }));
const updateStateFromAssistant = assistant => { const updateStateFromAssistant = assistant => {
const { config = {} } = assistant; const { config = {} } = assistant;
state.handoffMessage = config.handoff_message; state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message; state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.temperature = config.temperature || 1; state.temperature = config.temperature || 1;
}; };
const handleSystemMessagesUpdate = async () => { const handleSystemMessagesUpdate = async () => {
const validations = [ const result = await Promise.all([
v$.value.handoffMessage.$validate(), v$.value.handoffMessage.$validate(),
v$.value.resolutionMessage.$validate(), v$.value.resolutionMessage.$validate(),
]; ]).then(results => results.every(Boolean));
if (!isCaptainV2Enabled.value) {
validations.push(v$.value.instructions.$validate());
}
const result = await Promise.all(validations).then(results =>
results.every(Boolean)
);
if (!result) return; if (!result) return;
const payload = { const payload = {
@@ -84,10 +65,6 @@ const handleSystemMessagesUpdate = async () => {
}, },
}; };
if (!isCaptainV2Enabled.value) {
payload.config.instructions = state.instructions;
}
emit('submit', payload); emit('submit', payload);
}; };
@@ -118,16 +95,6 @@ watch(
:message-type="formErrors.resolutionMessage ? 'error' : 'info'" :message-type="formErrors.resolutionMessage ? 'error' : 'info'"
/> />
<Editor
v-if="!isCaptainV2Enabled"
v-model="state.instructions"
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
:message="formErrors.instructions"
:max-length="20000"
:message-type="formErrors.instructions ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12"> <label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }} {{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
@@ -8,13 +8,6 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DocumentForm from './DocumentForm.vue'; import DocumentForm from './DocumentForm.vue';
defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
@@ -55,11 +48,7 @@ defineExpose({ dialogRef });
:show-confirm-button="false" :show-confirm-button="false"
@close="handleClose" @close="handleClose"
> >
<DocumentForm <DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
:assistant-id="assistantId"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer /> <template #footer />
</Dialog> </Dialog>
</template> </template>
@@ -2,7 +2,7 @@
import { reactive, computed, ref, nextTick } from 'vue'; import { reactive, computed, ref, nextTick } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { minLength, requiredIf, url } from '@vuelidate/validators'; import { required, minLength, requiredIf, url } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
@@ -10,13 +10,6 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
assistantId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
@@ -25,11 +18,13 @@ const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainDocuments/getUIFlags'), uiFlags: useMapGetter('captainDocuments/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
name: '', name: '',
url: '', url: '',
assistantId: null,
documentType: 'url', documentType: 'url',
pdfFile: null, pdfFile: null,
}; };
@@ -43,11 +38,19 @@ const validationRules = {
url: requiredIf(() => state.documentType === 'url' && url), url: requiredIf(() => state.documentType === 'url' && url),
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)), minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
}, },
assistantId: { required },
pdfFile: { pdfFile: {
required: requiredIf(() => state.documentType === 'pdf'), required: requiredIf(() => state.documentType === 'pdf'),
}, },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const documentTypeOptions = [ const documentTypeOptions = [
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') }, { value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') }, { value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
@@ -67,6 +70,7 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
url: getErrorMessage('url', 'URL'), url: getErrorMessage('url', 'URL'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'), pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
})); }));
@@ -102,7 +106,7 @@ const openFileDialog = () => {
const prepareDocumentDetails = () => { const prepareDocumentDetails = () => {
const formData = new FormData(); const formData = new FormData();
formData.append('document[assistant_id]', props.assistantId); formData.append('document[assistant_id]', state.assistantId);
if (state.documentType === 'url') { if (state.documentType === 'url') {
formData.append('document[external_link]', state.url); formData.append('document[external_link]', state.url);
@@ -214,6 +218,21 @@ const handleSubmit = async () => {
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')" :placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
:message="formErrors.assistantId"
/>
</div>
<div class="flex gap-3 justify-between items-center w-full"> <div class="flex gap-3 justify-between items-center w-full">
<Button <Button
type="button" type="button"
@@ -1,5 +1,4 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue'; import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
@@ -7,7 +6,6 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@@ -22,7 +20,6 @@ const onClick = () => {
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
learn-more-url="https://chwt.app/captain-assistant" learn-more-url="https://chwt.app/captain-assistant"
class="mb-8" class="mb-8"
:hide-actions="!isOnChatwootCloud"
/> />
<EmptyStateLayout <EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')" :title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
@@ -1,5 +1,4 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue'; import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
@@ -7,7 +6,6 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
const emit = defineEmits(['click']); const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
@@ -21,7 +19,6 @@ const onClick = () => {
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg" fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
learn-more-url="https://chwt.app/captain-document" learn-more-url="https://chwt.app/captain-document"
:hide-actions="!isOnChatwootCloud"
class="mb-8" class="mb-8"
/> />
<EmptyStateLayout <EmptyStateLayout
@@ -1,63 +1,32 @@
<script setup> <script setup>
import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue'; import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue'; import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js'; import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { computed } from 'vue'; const emit = defineEmits(['click']);
const props = defineProps({
variant: {
type: String,
default: 'approved',
validator: value => ['approved', 'pending'].includes(value),
},
hasActiveFilters: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'clearFilters']);
const isApproved = computed(() => props.variant === 'approved');
const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const onClick = () => { const onClick = () => {
emit('click'); emit('click');
}; };
const onClearFilters = () => {
emit('clearFilters');
};
</script> </script>
<template> <template>
<FeatureSpotlight <FeatureSpotlight
v-if="isApproved"
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')" :note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg" fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg" fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
learn-more-url="https://chwt.app/captain-faq" learn-more-url="https://chwt.app/captain-faq"
:hide-actions="!isOnChatwootCloud"
class="mb-8" class="mb-8"
/> />
<EmptyStateLayout <EmptyStateLayout
:title=" :title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
isPending :subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']" :action-perms="['administrator']"
:show-backdrop="isApproved"
> >
<template v-if="isApproved" #empty-state-item> <template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden"> <div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ResponseCard <ResponseCard
v-for="(response, index) in responsesList.slice(0, 5)" v-for="(response, index) in responsesList.slice(0, 5)"
@@ -73,21 +42,11 @@ const onClearFilters = () => {
</div> </div>
</template> </template>
<template #actions> <template #actions>
<div class="flex flex-col items-center gap-3"> <Button
<Button :label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
v-if="isApproved" icon="i-lucide-plus"
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')" @click="onClick"
icon="i-lucide-plus" />
@click="onClick"
/>
<Button
v-else-if="isPending && hasActiveFilters"
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
variant="link"
size="sm"
@click="onClearFilters"
/>
</div>
</template> </template>
</EmptyStateLayout> </EmptyStateLayout>
</template> </template>
@@ -3,7 +3,6 @@ import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseForm from './ResponseForm.vue'; import ResponseForm from './ResponseForm.vue';
@@ -22,7 +21,6 @@ const props = defineProps({
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const route = useRoute();
const dialogRef = ref(null); const dialogRef = ref(null);
const responseForm = ref(null); const responseForm = ref(null);
@@ -41,15 +39,9 @@ const createResponse = responseDetails =>
const handleSubmit = async updatedResponse => { const handleSubmit = async updatedResponse => {
try { try {
if (props.type === 'edit') { if (props.type === 'edit') {
await updateResponse({ await updateResponse(updatedResponse);
...updatedResponse,
assistant_id: route.params.assistantId,
});
} else { } else {
await createResponse({ await createResponse(updatedResponse);
...updatedResponse,
assistant_id: route.params.assistantId,
});
} }
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`)); useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close(); dialogRef.value.close();
@@ -8,6 +8,7 @@ import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue'; import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({ const props = defineProps({
mode: { mode: {
@@ -20,17 +21,18 @@ const props = defineProps({
default: () => ({}), default: () => ({}),
}, },
}); });
const emit = defineEmits(['submit', 'cancel']); const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n(); const { t } = useI18n();
const formState = { const formState = {
uiFlags: useMapGetter('captainResponses/getUIFlags'), uiFlags: useMapGetter('captainResponses/getUIFlags'),
assistants: useMapGetter('captainAssistants/getRecords'),
}; };
const initialState = { const initialState = {
question: '', question: '',
answer: '', answer: '',
assistantId: null,
}; };
const state = reactive({ ...initialState }); const state = reactive({ ...initialState });
@@ -38,8 +40,16 @@ const state = reactive({ ...initialState });
const validationRules = { const validationRules = {
question: { required, minLength: minLength(1) }, question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) }, answer: { required, minLength: minLength(1) },
assistantId: { required },
}; };
const assistantList = computed(() =>
formState.assistants.value.map(assistant => ({
value: assistant.id,
label: assistant.name,
}))
);
const v$ = useVuelidate(validationRules, state); const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem); const isLoading = computed(() => formState.uiFlags.value.creatingItem);
@@ -53,6 +63,7 @@ const getErrorMessage = (field, errorKey) => {
const formErrors = computed(() => ({ const formErrors = computed(() => ({
question: getErrorMessage('question', 'QUESTION'), question: getErrorMessage('question', 'QUESTION'),
answer: getErrorMessage('answer', 'ANSWER'), answer: getErrorMessage('answer', 'ANSWER'),
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
})); }));
const handleCancel = () => emit('cancel'); const handleCancel = () => emit('cancel');
@@ -60,6 +71,7 @@ const handleCancel = () => emit('cancel');
const prepareDocumentDetails = () => ({ const prepareDocumentDetails = () => ({
question: state.question, question: state.question,
answer: state.answer, answer: state.answer,
assistant_id: state.assistantId,
}); });
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -74,11 +86,12 @@ const handleSubmit = async () => {
const updateStateFromResponse = response => { const updateStateFromResponse = response => {
if (!response) return; if (!response) return;
const { question, answer } = response; const { question, answer, assistant } = response;
Object.assign(state, { Object.assign(state, {
question, question,
answer, answer,
assistantId: assistant.id,
}); });
}; };
@@ -102,6 +115,7 @@ watch(
:message="formErrors.question" :message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'" :message-type="formErrors.question ? 'error' : 'info'"
/> />
<Editor <Editor
v-model="state.answer" v-model="state.answer"
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')" :label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
@@ -110,6 +124,22 @@ watch(
:max-length="10000" :max-length="10000"
:message-type="formErrors.answer ? 'error' : 'info'" :message-type="formErrors.answer ? 'error' : 'info'"
/> />
<div class="flex flex-col gap-1">
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
</label>
<ComboBox
id="assistant"
v-model="state.assistantId"
:options="assistantList"
:has-error="!!formErrors.assistantId"
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.assistantId"
/>
</div>
<div class="flex items-center justify-between w-full gap-3"> <div class="flex items-center justify-between w-full gap-3">
<Button <Button
type="button" type="button"
@@ -1,143 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const emit = defineEmits(['close', 'createAssistant']);
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const store = useStore();
const assistants = useMapGetter('captainAssistants/getRecords');
const currentAssistantId = computed(() => route.params.assistantId);
const isAssistantActive = assistant => {
return assistant.id === Number(currentAssistantId.value);
};
const fetchDataForRoute = async (routeName, assistantId) => {
const dataFetchMap = {
captain_assistants_responses_index: async () => {
await store.dispatch('captainResponses/get', { assistantId });
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
},
captain_assistants_responses_pending: async () => {
await store.dispatch('captainResponses/get', {
assistantId,
status: 'pending',
});
},
captain_assistants_documents_index: async () => {
await store.dispatch('captainDocuments/get', { assistantId });
},
captain_assistants_scenarios_index: async () => {
await store.dispatch('captainScenarios/get', { assistantId });
},
captain_assistants_playground_index: () => {
// Playground doesn't need pre-fetching, it loads on interaction
},
captain_assistants_inboxes_index: async () => {
await store.dispatch('captainInboxes/get', { assistantId });
},
captain_tools_index: async () => {
await store.dispatch('captainCustomTools/get', { page: 1 });
},
captain_assistants_settings_index: async () => {
await store.dispatch('captainAssistants/show', assistantId);
},
};
const fetchFn = dataFetchMap[routeName];
if (fetchFn) {
await fetchFn();
}
};
const handleAssistantChange = async assistant => {
if (isAssistantActive(assistant)) return;
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
await fetchDataForRoute(targetRouteName, assistant.id);
await router.push({
name: targetRouteName,
params: {
accountId: route.params.accountId,
assistantId: assistant.id,
},
});
emit('close');
};
const openCreateAssistantDialog = () => {
emit('createAssistant');
emit('close');
};
</script>
<template>
<div
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
>
<div
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
>
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<h2
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
</h2>
</div>
<p class="text-sm text-n-slate-11">
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
v-for="assistant in assistants"
:key="assistant.id"
:label="assistant.name"
variant="ghost"
color="slate"
trailing-icon
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
size="sm"
@click="handleAssistantChange(assistant)"
>
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"
:size="20"
icon-name="i-lucide-bot"
rounded-full
/>
</Button>
</div>
</div>
</template>
@@ -1,114 +0,0 @@
<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>
@@ -1,74 +0,0 @@
<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>
@@ -1,46 +0,0 @@
<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>
@@ -1,119 +0,0 @@
<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>
@@ -96,7 +96,6 @@ watch(
:label="selectedLabel" :label="selectedLabel"
trailing-icon trailing-icon
:disabled="disabled" :disabled="disabled"
no-animation
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand" class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
:class="{ :class="{
focused: open, focused: open,
@@ -78,10 +78,8 @@ const handleSuggestion = opt => {
</p> </p>
<router-link <router-link
:to="{ :to="{
name: 'captain_assistants_create_index', name: 'captain_assistants_index',
params: { params: { accountId: route.params.accountId },
accountId: route.params.accountId,
},
}" }"
class="text-n-slate-11 underline hover:text-n-slate-12" class="text-n-slate-11 underline hover:text-n-slate-12"
> >
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -54,17 +53,14 @@ const toggleSidebar = () => {
v-if="showCopilotLauncher" v-if="showCopilotLauncher"
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50" class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
> >
<ButtonGroup <div class="rounded-full bg-n-alpha-2 p-1">
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-woot-captain" icon="i-woot-captain"
no-animation class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</ButtonGroup> </div>
</div> </div>
<template v-else /> <template v-else />
</template> </template>
@@ -10,7 +10,6 @@ defineProps({
fallbackThumbnail: { type: String, default: '' }, fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' }, fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' }, learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
}); });
const imageError = ref(false); const imageError = ref(false);
@@ -66,7 +65,7 @@ const openLink = link => {
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8"> <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> <p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
<div v-if="!hideActions" class="flex gap-3"> <div class="flex gap-3">
<slot name="actions"> <slot name="actions">
<Button <Button
v-if="videoUrl" v-if="videoUrl"
@@ -13,7 +13,6 @@ defineProps({
fallbackThumbnail: { type: String, default: '' }, fallbackThumbnail: { type: String, default: '' },
fallbackThumbnailDark: { type: String, default: '' }, fallbackThumbnailDark: { type: String, default: '' },
learnMoreUrl: { type: String, default: '' }, learnMoreUrl: { type: String, default: '' },
hideActions: { type: Boolean, default: false },
}); });
const imageError = ref(false); const imageError = ref(false);
@@ -93,7 +92,7 @@ const openLink = link => {
{{ note }} {{ note }}
</p> </p>
<div v-if="!hideActions" class="flex gap-3 justify-between w-full"> <div class="flex gap-3 justify-between w-full">
<slot name="actions"> <slot name="actions">
<Button <Button
v-if="videoUrl" v-if="videoUrl"
@@ -1,29 +0,0 @@
<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>
@@ -1,32 +0,0 @@
<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>
@@ -1,25 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -1,68 +0,0 @@
<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>
@@ -1,14 +0,0 @@
<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>
@@ -47,7 +47,6 @@ const isReel = computed(() => {
'max-w-48': isReel, 'max-w-48': isReel,
'max-w-full': !isReel, 'max-w-full': !isReel,
}" }"
@click.stop
@error="handleError" @error="handleError"
/> />
</div> </div>
@@ -1,21 +0,0 @@
<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>
@@ -1,23 +0,0 @@
<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>
@@ -1,21 +0,0 @@
<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>
@@ -1,23 +0,0 @@
<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>
@@ -1,21 +0,0 @@
<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>
@@ -1,20 +0,0 @@
<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>
@@ -2,7 +2,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
defineProps({ defineProps({
isMobileSidebarOpen: { isMobileSidebarOpen: {
@@ -46,17 +45,14 @@ const toggleSidebar = () => {
}, },
]" ]"
> >
<ButtonGroup <div class="rounded-full bg-n-alpha-2 p-1">
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
>
<Button <Button
icon="i-lucide-menu" icon="i-lucide-menu"
no-animation class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
lg lg
@click="toggleSidebar" @click="toggleSidebar"
/> />
</ButtonGroup> </div>
</div> </div>
<template v-else /> <template v-else />
</template> </template>
@@ -13,7 +13,6 @@ import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue'; import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue'; import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import ChannelLeaf from './ChannelLeaf.vue'; import ChannelLeaf from './ChannelLeaf.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue'; import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue'; import Logo from 'next/icon/Logo.vue';
@@ -33,15 +32,11 @@ const emit = defineEmits([
'closeMobileSidebar', 'closeMobileSidebar',
]); ]);
const { accountScopedRoute, isOnChatwootCloud } = useAccount(); const { accountScopedRoute } = useAccount();
const store = useStore(); const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']); const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n(); const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const toggleShortcutModalFn = show => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -221,70 +216,26 @@ const menuItems = computed(() => {
name: 'Captain', name: 'Captain',
icon: 'i-woot-captain', icon: 'i-woot-captain',
label: t('SIDEBAR.CAPTAIN'), label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [ children: [
{ {
name: 'FAQs', name: 'Assistants',
label: t('SIDEBAR.CAPTAIN_RESPONSES'), label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
activeOn: [ to: accountScopedRoute('captain_assistants_index'),
'captain_assistants_responses_index',
'captain_assistants_responses_pending',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_responses_index',
}),
}, },
{ {
name: 'Documents', name: 'Documents',
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'), label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
activeOn: ['captain_assistants_documents_index'], to: accountScopedRoute('captain_documents_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_documents_index',
}),
}, },
{ {
name: 'Scenarios', name: 'Responses',
label: t('SIDEBAR.CAPTAIN_SCENARIOS'), label: t('SIDEBAR.CAPTAIN_RESPONSES'),
activeOn: ['captain_assistants_scenarios_index'], to: accountScopedRoute('captain_responses_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_scenarios_index',
}),
},
{
name: 'Playground',
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
activeOn: ['captain_assistants_playground_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_playground_index',
}),
},
{
name: 'Inboxes',
label: t('SIDEBAR.CAPTAIN_INBOXES'),
activeOn: ['captain_assistants_inboxes_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_inboxes_index',
}),
}, },
{ {
name: 'Tools', name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'), label: t('SIDEBAR.CAPTAIN_TOOLS'),
activeOn: ['captain_tools_index'], to: accountScopedRoute('captain_tools_index'),
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_tools_index',
}),
},
{
name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
activeOn: [
'captain_assistants_settings_index',
'captain_assistants_guidelines_index',
'captain_assistants_guardrails_index',
],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_settings_index',
}),
}, },
], ],
}, },
@@ -581,20 +532,20 @@ const menuItems = computed(() => {
]" ]"
> >
<section class="grid gap-2 mt-2 mb-4"> <section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 items-center px-2 min-w-0"> <div class="flex items-center min-w-0 gap-2 px-2">
<div class="grid flex-shrink-0 place-content-center size-6"> <div class="grid flex-shrink-0 size-6 place-content-center">
<Logo class="size-4" /> <Logo class="size-4" />
</div> </div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" /> <div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher <SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0" class="flex-grow min-w-0 -mx-1"
@show-create-account-modal="emit('showCreateAccountModal')" @show-create-account-modal="emit('showCreateAccountModal')"
/> />
</div> </div>
<div class="flex gap-2 px-2"> <div class="flex gap-2 px-2">
<RouterLink <RouterLink
:to="{ name: 'search' }" :to="{ name: 'search' }"
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" 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"
> >
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" /> <span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left"> <span class="flex-grow text-left">
@@ -619,7 +570,7 @@ const menuItems = computed(() => {
</ComposeConversation> </ComposeConversation>
</div> </div>
</section> </section>
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar"> <nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none"> <ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup <SidebarGroup
v-for="item in menuItems" v-for="item in menuItems"
@@ -629,21 +580,11 @@ const menuItems = computed(() => {
</ul> </ul>
</nav> </nav>
<section <section
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center" 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"
> >
<div <SidebarProfileMenu
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent" @open-key-shortcut-modal="emit('openKeyShortcutModal')"
/> />
<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> </section>
</aside> </aside>
</template> </template>
@@ -1,110 +0,0 @@
<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>
@@ -19,46 +19,19 @@ export function useSidebarContext() {
return '/'; return '/';
}; };
// Helper to find route definition by name without resolving
const findRouteByName = name => {
const routes = router.getRoutes();
return routes.find(route => route.name === name);
};
const resolvePermissions = to => { const resolvePermissions = to => {
if (!to) return []; if (to) return router.resolve(to)?.meta?.permissions ?? [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.permissions ?? [];
}
return router.resolve(to)?.meta?.permissions ?? [];
}; };
const resolveFeatureFlag = to => { const resolveFeatureFlag = to => {
if (!to) return ''; if (to) return router.resolve(to)?.meta?.featureFlag || '';
return '';
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.featureFlag || '';
}
return router.resolve(to)?.meta?.featureFlag || '';
}; };
const resolveInstallationType = to => { const resolveInstallationType = to => {
if (!to) return []; if (to) return router.resolve(to)?.meta?.installationTypes || [];
return [];
// If navigationPath param exists, get the target route definition
if (to.params?.navigationPath) {
const targetRoute = findRouteByName(to.params.navigationPath);
return targetRoute?.meta?.installationTypes || [];
}
return router.resolve(to)?.meta?.installationTypes || [];
}; };
const isAllowed = to => { const isAllowed = to => {
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { computed, ref, onMounted, nextTick } from 'vue'; import { computed } from 'vue';
import { useResizeObserver } from '@vueuse/core';
const props = defineProps({ const props = defineProps({
initialActiveTab: { initialActiveTab: {
type: Number, type: Number,
@@ -24,32 +22,6 @@ const emit = defineEmits(['tabChanged']);
const activeTab = computed(() => props.initialActiveTab); const activeTab = computed(() => props.initialActiveTab);
const tabRefs = ref([]);
const indicatorStyle = ref({});
const enableTransition = ref(false);
const activeElement = computed(() => tabRefs.value[activeTab.value]);
const updateIndicator = () => {
if (!activeElement.value) return;
indicatorStyle.value = {
left: `${activeElement.value.offsetLeft}px`,
width: `${activeElement.value.offsetWidth}px`,
};
};
useResizeObserver(activeElement, () => {
if (enableTransition.value || !activeElement.value) updateIndicator();
});
onMounted(() => {
updateIndicator();
nextTick(() => {
enableTransition.value = true;
});
});
const selectTab = index => { const selectTab = index => {
emit('tabChanged', props.tabs[index]); emit('tabChanged', props.tabs[index]);
}; };
@@ -65,30 +37,20 @@ const showDivider = index => {
</script> </script>
<template> <template>
<div <div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
>
<div
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
:style="indicatorStyle"
/>
<template v-for="(tab, index) in tabs" :key="index"> <template v-for="(tab, index) in tabs" :key="index">
<button <button
:ref="el => (tabRefs[index] = el)" class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[ :class="[
activeTab === index activeTab === index
? 'text-n-blue-text scale-100' ? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
: 'text-n-slate-10 scale-[0.98]', : 'text-n-slate-10 outline-transparent h-8',
]" ]"
@click="selectTab(index)" @click="selectTab(index)"
> >
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }} {{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
</button> </button>
<div <div
v-if="index < tabs.length - 1"
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out" class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
:class=" :class="
showDivider(index) showDivider(index)
@@ -15,7 +15,6 @@ import {
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const store = useStore(); const store = useStore();
@@ -134,7 +133,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template> <template>
<div class="relative flex items-center justify-end resolve-actions"> <div class="relative flex items-center justify-end resolve-actions">
<ButtonGroup <div
class="rounded-lg shadow outline-1 outline flex-shrink-0" class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
@@ -143,7 +142,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')" :label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdResolveConversation" @click="onCmdResolveConversation"
@@ -153,7 +151,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')" :label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0" class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
@@ -163,7 +160,6 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:label="t('CONVERSATION.HEADER.OPEN_ACTION')" :label="t('CONVERSATION.HEADER.OPEN_ACTION')"
size="sm" size="sm"
color="slate" color="slate"
no-animation
:is-loading="isLoading" :is-loading="isLoading"
@click="onCmdOpenConversation" @click="onCmdOpenConversation"
/> />
@@ -173,13 +169,12 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="i-lucide-chevron-down" icon="i-lucide-chevron-down"
:disabled="isLoading" :disabled="isLoading"
size="sm" size="sm"
no-animation
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0" class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
color="slate" color="slate"
trailing-icon trailing-icon
@click="openDropdown" @click="openDropdown"
/> />
</ButtonGroup> </div>
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
@@ -302,16 +302,7 @@ function isBodyEmpty(content) {
} }
function handleEmptyBodyWithSignature() { function handleEmptyBodyWithSignature() {
const { schema, tr, doc } = state; const { schema, tr } = state;
const isEmptyParagraph = node =>
node && node.type === schema.nodes.paragraph && node.content.size === 0;
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
if (isEmptyParagraph(doc.firstChild)) {
focusEditorInputField('start');
return;
}
// create a paragraph node and // create a paragraph node and
// start a transaction to append it at the end // start a transaction to append it at the end
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
<template> <template>
<button <button
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75" class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
:disabled="disabled" :disabled="disabled"
:class="{ :class="{
'cursor-not-allowed': disabled, 'cursor-not-allowed': disabled,
@@ -1,129 +1,100 @@
<script setup> <script>
import { ref, computed } from 'vue'; import { mapGetters } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']); export default {
components: {
const { t } = useI18n(); NextButton,
},
const labels = useMapGetter('labels/getLabels'); emits: ['update', 'close', 'assign'],
data() {
const query = ref(''); return {
const selectedLabels = ref([]); query: '',
selectedLabels: [],
const filteredLabels = computed(() => { };
if (!query.value) return labels.value; },
return labels.value.filter(label => computed: {
label.title.toLowerCase().includes(query.value.toLowerCase()) ...mapGetters({ labels: 'labels/getLabels' }),
); filteredLabels() {
}); return this.labels.filter(label =>
label.title.toLowerCase().includes(this.query.toLowerCase())
const hasLabels = computed(() => labels.value.length > 0); );
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0); },
},
const isLabelSelected = label => { methods: {
return selectedLabels.value.includes(label); isLabelSelected(label) {
}; return this.selectedLabels.includes(label);
},
const onClose = () => { assignLabels(key) {
emit('close'); this.$emit('update', key);
}; },
onClose() {
const handleAssign = () => { this.$emit('close');
if (selectedLabels.value.length > 0) { },
emit('assign', selectedLabels.value); },
}
}; };
</script> </script>
<template> <template>
<div <div v-on-clickaway="onClose" class="labels-container">
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle"> <div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24"> <svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" /> <path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg> </svg>
</div> </div>
<div class="flex items-center justify-between p-2.5"> <div class="flex items-center justify-between header">
<span class="text-sm font-medium">{{ <span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" /> <NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div> </div>
<div class="flex flex-col max-h-60 min-h-0"> <div class="labels-list">
<header class="py-2 px-2.5"> <header class="labels-list__header">
<Input <div
v-model="query" class="flex items-center justify-between h-8 gap-2 label-list-search"
type="search" >
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" <fluent-icon icon="search" class="search-icon" size="16" />
icon-left="i-lucide-search" <input
size="sm" v-model="query"
class="w-full" type="search"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')" :placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/> class="reset-base !outline-0 !text-sm label--search_input"
/>
</div>
</header> </header>
<ul <ul class="labels-list__body">
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li <li
v-for="label in filteredLabels" v-for="label in filteredLabels"
:key="label.id" :key="label.id"
class="my-1 mx-0 py-0 px-2.5" class="label__list-item"
role="option"
:aria-selected="isLabelSelected(label.title)"
> >
<label <label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2" class="item"
:class="{ 'label-selected': isLabelSelected(label.title) }"
> >
<input <input
v-model="selectedLabels" v-model="selectedLabels"
type="checkbox" type="checkbox"
:value="label.title" :value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5" class="label-checkbox"
:aria-label="label.title"
/> />
<span <span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis" class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
> >
{{ label.title }} {{ label.title }}
</span> </span>
<span <span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak" class="label-pill"
:style="{ backgroundColor: label.color }" :style="{ backgroundColor: label.color }"
/> />
</label> </label>
</li> </li>
</ul> </ul>
<div v-else class="p-2 text-center"> <footer class="labels-list__footer">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton <NextButton
sm sm
type="submit" type="submit"
class="w-full" :label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length" :disabled="!selectedLabels.length"
@click="handleAssign" @click="$emit('assign', selectedLabels)"
/> />
</footer> </footer>
</div> </div>
@@ -131,11 +102,107 @@ const handleAssign = () => {
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.triangle { .labels-list {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position]; @apply flex flex-col max-h-[15rem] min-h-[auto];
svg path { .labels-list__header {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak; @apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
}
.labels-list__body {
@apply flex-1 overflow-y-auto py-2.5 mx-0;
}
.labels-list__footer {
@apply p-2;
button {
@apply w-full;
.button__content {
@apply text-center;
}
}
} }
} }
.label-list-search {
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.label--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
.labels-container {
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply max-h-[15rem] overflow-y-auto;
.label__list-container {
@apply h-full;
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
}
.labels-placeholder {
@apply p-2;
}
.label__list-item {
@apply my-1 mx-0 py-0 px-2.5;
.item {
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
&.label-selected {
@apply bg-n-slate-2;
}
span {
@apply text-sm;
}
.label-checkbox {
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
}
.label-title {
@apply flex-grow w-full;
}
.label-pill {
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
}
}
}
.search-container {
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
}
</style> </style>
@@ -14,7 +14,7 @@ import {
} from 'dashboard/constants/automation'; } from 'dashboard/constants/automation';
/** /**
* This is a shared composables that holds utilities used to build dropdown and file options * This is a shared composables that holds utilites used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties. * @returns {Object} An object containing various automation-related functions and computed properties.
*/ */
export default function useAutomationValues() { export default function useAutomationValues() {
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
enable_audio_alerts: audioAlertType, enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist, alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone, notification_tone: audioAlertTone,
// UI Settings can be undefined initially as we don't send the // UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process. // entire payload for the user during the signup process.
} = uiSettings || {}; } = uiSettings || {};
@@ -24,7 +24,7 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found", "NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again." "ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -571,27 +571,6 @@
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙" "ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
} }
}, },
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": { "COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": { "CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again." "ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
@@ -648,16 +648,14 @@
"TIER_1K": "1K customers per 24h", "TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h", "TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h", "TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h", "TIER_UNLIMITED": "Unlimited customers per 24h"
"UNKNOWN": "Rating not available"
}, },
"STATUSES": { "STATUSES": {
"APPROVED": "Approved", "APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review", "PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review", "AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected", "REJECTED": "Rejected",
"DECLINED": "Declined", "DECLINED": "Declined"
"NON_EXISTS": "Non exists"
}, },
"MODES": { "MODES": {
"SANDBOX": "Sandbox", "SANDBOX": "Sandbox",
@@ -752,118 +752,8 @@
} }
} }
}, },
"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": { "RESPONSES": {
"HEADER": "FAQs", "HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ", "ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
@@ -903,10 +793,6 @@
"APPROVED": "Approved", "APPROVED": "Approved",
"ALL": "All" "ALL": "All"
}, },
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.", "FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": { "CREATE": {
"TITLE": "Add an FAQ", "TITLE": "Add an FAQ",
@@ -937,15 +823,13 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved" "APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
}, },
"OPTIONS": { "OPTIONS": {
"APPROVE": "Approve", "APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit", "EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete" "DELETE_RESPONSE": "Delete FAQ"
}, },
"EMPTY_STATE": { "EMPTY_STATE": {
"TITLE": "No FAQs Found", "TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.", "SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": { "FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ", "TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it." "NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -51,7 +51,6 @@
}, },
"DATE_RANGE_OPTIONS": { "DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days", "LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days", "LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months", "LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months", "LAST_6_MONTHS": "Last 6 months",
@@ -267,8 +266,6 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports", "DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox", "FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@@ -470,13 +467,6 @@
"CONVERSATIONS": "{count} conversations", "CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report" "DOWNLOAD_REPORT": "Download report"
}, },
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": { "AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents", "HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Loading agent metrics...", "LOADING_MESSAGE": "Loading agent metrics...",
@@ -304,7 +304,6 @@
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs", "CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home", "HOME": "Home",
"AGENTS": "Agents", "AGENTS": "Agents",
"AGENT_BOTS": "Bots", "AGENT_BOTS": "Bots",
@@ -24,7 +24,7 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "إضافة وسم", "ASSIGN_LABELS": "إضافة وسم",
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات", "NO_LABELS_FOUND": "لم يتم العثور على تسميات لـ",
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة", "ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.", "ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى." "ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
@@ -571,27 +571,6 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙" "ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} }
}, },
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "تعيين التسميات",
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "حذف",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "حذف جهة الاتصال"
}
},
"COMPOSE_NEW_CONVERSATION": { "COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": { "CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again." "ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
@@ -648,16 +648,14 @@
"TIER_1K": "1K customers per 24h", "TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h", "TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h", "TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h", "TIER_UNLIMITED": "Unlimited customers per 24h"
"UNKNOWN": "Rating not available"
}, },
"STATUSES": { "STATUSES": {
"APPROVED": "Approved", "APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review", "PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review", "AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected", "REJECTED": "Rejected",
"DECLINED": "Declined", "DECLINED": "Declined"
"NON_EXISTS": "Non exists"
}, },
"MODES": { "MODES": {
"SANDBOX": "Sandbox", "SANDBOX": "Sandbox",
@@ -752,118 +752,8 @@
} }
} }
}, },
"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": { "RESPONSES": {
"HEADER": "FAQs", "HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ", "ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
@@ -903,10 +793,6 @@
"APPROVED": "Approved", "APPROVED": "Approved",
"ALL": "الكل" "ALL": "الكل"
}, },
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.", "FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": { "CREATE": {
"TITLE": "Add an FAQ", "TITLE": "Add an FAQ",
@@ -937,15 +823,13 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved" "APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
}, },
"OPTIONS": { "OPTIONS": {
"APPROVE": "Approve", "APPROVE": "Mark as approved",
"EDIT_RESPONSE": "تعديل", "EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "حذف" "DELETE_RESPONSE": "Delete FAQ"
}, },
"EMPTY_STATE": { "EMPTY_STATE": {
"TITLE": "No FAQs Found", "TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.", "SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": { "FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ", "TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it." "NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -51,7 +51,6 @@
}, },
"DATE_RANGE_OPTIONS": { "DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "آخر 7 أيام", "LAST_7_DAYS": "آخر 7 أيام",
"LAST_14_DAYS": "آخر 14 يوماً",
"LAST_30_DAYS": "آخر 30 يوماً", "LAST_30_DAYS": "آخر 30 يوماً",
"LAST_3_MONTHS": "آخر 3 أشهر", "LAST_3_MONTHS": "آخر 3 أشهر",
"LAST_6_MONTHS": "آخر 6 أشهر", "LAST_6_MONTHS": "آخر 6 أشهر",
@@ -267,8 +266,6 @@
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.", "NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
"DOWNLOAD_INBOX_REPORTS": "تحميل تقارير صندوق الوارد", "DOWNLOAD_INBOX_REPORTS": "تحميل تقارير صندوق الوارد",
"FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد", "FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "المحادثات", "NAME": "المحادثات",
@@ -470,13 +467,6 @@
"CONVERSATIONS": "{count} conversations", "CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "تنزيل التقرير" "DOWNLOAD_REPORT": "تنزيل التقرير"
}, },
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "تنزيل التقرير"
},
"AGENT_CONVERSATIONS": { "AGENT_CONVERSATIONS": {
"HEADER": "المحادثات من قبل الوكلاء", "HEADER": "المحادثات من قبل الوكلاء",
"LOADING_MESSAGE": "جاري تحميل مقاييس الوكيل...", "LOADING_MESSAGE": "جاري تحميل مقاييس الوكيل...",
@@ -304,7 +304,6 @@
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs", "CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "الرئيسية", "HOME": "الرئيسية",
"AGENTS": "وكيل الدعم", "AGENTS": "وكيل الدعم",
"AGENT_BOTS": "الروبوتات", "AGENT_BOTS": "الروبوتات",
@@ -24,7 +24,7 @@
}, },
"LABELS": { "LABELS": {
"ASSIGN_LABELS": "Assign labels", "ASSIGN_LABELS": "Assign labels",
"NO_LABELS_FOUND": "No labels found", "NO_LABELS_FOUND": "No labels found for",
"ASSIGN_SELECTED_LABELS": "Assign selected labels", "ASSIGN_SELECTED_LABELS": "Assign selected labels",
"ASSIGN_SUCCESFUL": "Labels assigned successfully.", "ASSIGN_SUCCESFUL": "Labels assigned successfully.",
"ASSIGN_FAILED": "Failed to assign labels. Please try again." "ASSIGN_FAILED": "Failed to assign labels. Please try again."
@@ -571,27 +571,6 @@
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙" "ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} }
}, },
"CONTACTS_BULK_ACTIONS": {
"ASSIGN_LABELS": "Assign Labels",
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"CLEAR_SELECTION": "Clear selection",
"SELECT_ALL": "Select all ({count})",
"DELETE_CONTACTS": "Delete",
"DELETE_SUCCESS": "Contacts deleted successfully.",
"DELETE_FAILED": "Failed to delete contacts.",
"DELETE_DIALOG": {
"TITLE": "Delete selected contacts",
"SINGULAR_TITLE": "Delete selected contact",
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
"CONFIRM_MULTIPLE": "Delete contacts",
"CONFIRM_SINGLE": "Delete contact"
}
},
"COMPOSE_NEW_CONVERSATION": { "COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": { "CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again." "ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
@@ -648,16 +648,14 @@
"TIER_1K": "1K customers per 24h", "TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h", "TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h", "TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h", "TIER_UNLIMITED": "Unlimited customers per 24h"
"UNKNOWN": "Rating not available"
}, },
"STATUSES": { "STATUSES": {
"APPROVED": "Approved", "APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review", "PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review", "AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected", "REJECTED": "Rejected",
"DECLINED": "Declined", "DECLINED": "Declined"
"NON_EXISTS": "Non exists"
}, },
"MODES": { "MODES": {
"SANDBOX": "Sandbox", "SANDBOX": "Sandbox",
@@ -752,118 +752,8 @@
} }
} }
}, },
"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": { "RESPONSES": {
"HEADER": "FAQs", "HEADER": "FAQs",
"PENDING_FAQS": "Pending FAQs",
"ADD_NEW": "Create new FAQ", "ADD_NEW": "Create new FAQ",
"DOCUMENTABLE": { "DOCUMENTABLE": {
"CONVERSATION": "Conversation #{id}" "CONVERSATION": "Conversation #{id}"
@@ -903,10 +793,6 @@
"APPROVED": "Approved", "APPROVED": "Approved",
"ALL": "All" "ALL": "All"
}, },
"PENDING_BANNER": {
"TITLE": "Captain has found some FAQs your customers were looking for.",
"ACTION": "Click here to review"
},
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.", "FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
"CREATE": { "CREATE": {
"TITLE": "Add an FAQ", "TITLE": "Add an FAQ",
@@ -937,15 +823,13 @@
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved" "APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
}, },
"OPTIONS": { "OPTIONS": {
"APPROVE": "Approve", "APPROVE": "Mark as approved",
"EDIT_RESPONSE": "Edit", "EDIT_RESPONSE": "Edit FAQ",
"DELETE_RESPONSE": "Delete" "DELETE_RESPONSE": "Delete FAQ"
}, },
"EMPTY_STATE": { "EMPTY_STATE": {
"TITLE": "No FAQs Found", "TITLE": "No FAQs Found",
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.", "SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
"CLEAR_SEARCH": "Clear active filters",
"FEATURE_SPOTLIGHT": { "FEATURE_SPOTLIGHT": {
"TITLE": "Captain FAQ", "TITLE": "Captain FAQ",
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it." "NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
@@ -51,7 +51,6 @@
}, },
"DATE_RANGE_OPTIONS": { "DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days", "LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days", "LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months", "LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months", "LAST_6_MONTHS": "Last 6 months",
@@ -267,8 +266,6 @@
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.", "NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports", "DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
"FILTER_DROPDOWN_LABEL": "Select Inbox", "FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": { "METRICS": {
"CONVERSATIONS": { "CONVERSATIONS": {
"NAME": "Conversations", "NAME": "Conversations",
@@ -470,13 +467,6 @@
"CONVERSATIONS": "{count} conversations", "CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report" "DOWNLOAD_REPORT": "Download report"
}, },
"RESOLUTION_HEATMAP": {
"HEADER": "Resolutions",
"NO_CONVERSATIONS": "No conversations",
"CONVERSATION": "{count} conversation",
"CONVERSATIONS": "{count} conversations",
"DOWNLOAD_REPORT": "Download report"
},
"AGENT_CONVERSATIONS": { "AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents", "HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Loading agent metrics...", "LOADING_MESSAGE": "Loading agent metrics...",

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