Compare commits

..
Author SHA1 Message Date
google-labs-jules[bot] 99ef76562b Allow agents to export contacts
This change modifies the ContactPolicy to allow users with the agent role to export contacts. Previously, only administrators could perform this action.

Includes tests to verify that both agents and administrators can export contacts.
2025-06-09 16:47:58 +00:00
google-labs-jules[bot] 3b29214dfc Allow agents to export contacts
This change modifies the ContactPolicy to allow users with the agent role to export contacts. Previously, only administrators could perform this action.

Includes tests to verify that both agents and administrators can export contacts.
2025-06-09 16:40:30 +00:00
25f947223d feat: sanitize inbox name (#11597)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-09 14:46:12 +05:30
PranavandGitHub 9b43a0f72b fix: Retry job if file not found (#11683)
Removed StandardError rescue blocks and added retry_on for
ResponseBuilderJob and AudioTranscriptionJob
2025-06-05 22:53:11 -05:00
Sojan JoseandGitHub 10363e77ad chore: Add region option to Dialogflow integration (#11510)
## Summary
- support region option when configuring Dialogflow integration
- connect to region endpoint when set
- use session identification based on the region

Fixes: https://github.com/chatwoot/chatwoot/issues/4129
2025-06-05 20:01:17 -05:00
Shivam MishraandGitHub 27bce50210 fix: Incorrect date parsing in matchesFilter (#11679)
The `matchesFilter` is a utility that checks the incoming payload
against a filter and returns `true` or `false`.
For the `greater_than` and `less_than` filter specifically, the date
parsing would fail when the timestamp was a 10 digit number.

This PR solves this by adding a `coerceToDate` method that tries to
parse the given value to a Date object as correctly as possible before
comparing.

Ref: https://github.com/chatwoot/utils/pull/53
2025-06-05 19:38:56 -05:00
8bc00f707b feat(ee): Add transcription support for audio messages (#11670)
<img width="419" alt="Screenshot 2025-06-03 at 4 25 37 PM"
src="https://github.com/user-attachments/assets/4b6ddd11-9b91-4981-a571-83746cc4d40b"
/>


Fixes https://github.com/chatwoot/chatwoot/issues/10182

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-06-05 18:29:37 -05:00
273c277d47 feat: Add conversation delete feature (#11677)
<img width="1240" alt="Screenshot 2025-06-05 at 12 39 04 AM"
src="https://github.com/user-attachments/assets/0071cd23-38c3-4638-946e-f1fbd11ec845"
/>


## Changes

Give the admins an option to delete conversation via the context menu

- enable conversation deletion in routes and controller
- expose delete API on conversations
- add delete option in conversation context menu and integrate with card
and list
- implement store action and mutation for delete
- update i18n with new strings

fixes: https://github.com/chatwoot/chatwoot/issues/947

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-06-05 15:53:17 -05:00
4c0d096e4d feat: Add rich text support for widget welcome tagline (#11666)
# Pull Request Template

## Description

This PR adds rich text support for the widget welcome tagline, enabling
users to format the message using basic text styling (bold, italics,
links, etc.)
https://github.com/chatwoot/chatwoot/pull/11629#issuecomment-2932448975

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screenshots
<img width="1100" alt="image"
src="https://github.com/user-attachments/assets/eef2bfd3-7bc9-4aea-b21d-078f6b29f334"
/>
<img width="448" alt="image"
src="https://github.com/user-attachments/assets/713c6b07-d0dc-4c8f-bfba-cd119a858375"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-05 14:13:46 -05:00
Sojan JoseandGitHub 2f40f95f77 fix: broken link in admin account user list (#11661)
- Add a dummy link for account users page that redirect back to user
page
- move common account user form in user and account super admin pages to
a partial

fixes:  #10757
2025-06-04 21:37:05 -05:00
Sivin VargheseandGitHub ee293c3598 fix: Show default dashboard prompt for contact and articles (#11675)
# Pull Request Template

## Description

This PR fixes an issue where the copilot displayed irrelevant prompts on
the Contacts and Article pages.

Previously, conversation-specific prompts like "Summarize", "Suggest
answer", and "Rate the conversation" were shown even outside
conversations. With this update, users navigating to the Contacts or
Articles routes will now see appropriate dashboard prompts such as "High
priority conversation" and "List contacts".

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

### Screenshots

**Contacts Page**
<img width="1401" alt="image"
src="https://github.com/user-attachments/assets/242f42a1-d9a5-4369-bd00-5c22e977970d"
/>

**Articles page**
<img width="1401" alt="image"
src="https://github.com/user-attachments/assets/12c5575d-9c50-4530-a0c9-2f02fbf4c732"
/>



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2025-06-04 13:54:51 -05:00
raza-akandGitHub 513d954027 feat: Show active Contacts (#8243) 2025-06-04 21:52:13 +05:30
e9a132a923 feat: auto resolve label option and fixes (#11541)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-06-04 14:53:24 +05:30
Sojan JoseandGitHub b70d2c0ebe chore: Improvements for codespace (#11667)
- move to vite build instead of dev for codespaces
- disable miniprofiler
2025-06-04 00:21:23 -05:00
Tanmay Deep SharmaandGitHub 623734a631 chore: mintlify docs migrate (#11641)
Remove the developer docs from this repo and move it to
https://github.com/chatwoot/docs


PR - https://github.com/chatwoot/docs/pull/542
2025-06-03 22:42:00 -05:00
PranavandGitHub b81f1bc971 fix: Reset conversation sidebar when copilot is open (#11657)
Both the Copilot and the conversation sidebar were open at the same
time, which shouldn’t be possible.
2025-06-03 13:39:18 -05:00
Sivin VargheseandGitHub ff0ad53f49 fix: Style issue with conversation header (#11655) 2025-06-03 15:21:30 +05:30
Shivam MishraandGitHub 02c4863d95 feat: improve article search ranking (#11640) 2025-06-03 12:01:17 +05:30
PranavandGitHub bae958334d feat: Update UI for Copilot (#11561)
- Updated UI for copilot
2025-06-02 22:02:03 -05:00
147 changed files with 2319 additions and 985 deletions
+7 -1
View File
@@ -44,6 +44,12 @@ force_run:
rm -f tmp/pids/*.pid rm -f tmp/pids/*.pid
overmind start -f Procfile.dev overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug: debug:
overmind connect backend overmind connect backend
@@ -53,4 +59,4 @@ debug_worker:
docker: docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile . docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker .PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
+4
View File
@@ -0,0 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index def index
@contacts_count = resolved_contacts.count @contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id)) .get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count @contacts_count = contacts.count
@contacts = contacts.page(@current_page) @contacts = fetch_contacts(contacts)
end end
def show; end def show; end
@@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save! @conversation.save!
end end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private private
def permitted_update_params def permitted_update_params
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern include GoogleConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url( redirect_url = google_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/google/callback", redirect_uri: "#{base_url}/google/callback",
scope: scope, scope: 'email profile https://mail.google.com/',
response_type: 'code', response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online' access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
} }
) )
if redirect_url if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern include InstagramConcern
include Instagram::IntegrationHelper include Instagram::IntegrationHelper
before_action :check_authorization
def create def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern include MicrosoftConcern
before_action :check_authorization
def create def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url( redirect_url = microsoft_client.auth_code.authorize_url(
{ {
redirect_uri: "#{base_url}/microsoft/callback", redirect_uri: "#{base_url}/microsoft/callback",
scope: scope, scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
state: state,
prompt: 'consent' prompt: 'consent'
} }
) )
if redirect_url if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity
end end
end end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def settings_params def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting) params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end end
def check_signup_enabled def check_signup_enabled
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private private
def scope def base_url
'https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email https://mail.google.com/' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private private
def scope def base_url
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile' ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end
end end
@@ -12,16 +12,6 @@ class Google::CallbacksController < OauthCallbackController
private private
def verify_scopes
granted_scopes = parsed_body['scope']&.split || []
required_scopes = scope.split
missing_scopes = required_scopes - granted_scopes
return if missing_scopes.empty?
raise StandardError, I18n.t('errors.oauth.insufficient_scopes', scopes: missing_scopes.join(', '))
end
def provider_name def provider_name
'google' 'google'
end end
@@ -34,13 +24,4 @@ class Google::CallbacksController < OauthCallbackController
# from GoogleConcern # from GoogleConcern
google_client google_client
end end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
error_message = exception.message || I18n.t('errors.oauth.authorization_failed')
redirect_url = "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/app/accounts/#{account.id}/settings/inboxes/new/email"
redirect_to "#{redirect_url}?error=#{CGI.escape(error_message)}"
end
end end
+9 -18
View File
@@ -5,10 +5,11 @@ class OauthCallbackController < ApplicationController
redirect_uri: "#{base_url}/#{provider_name}/callback" redirect_uri: "#{base_url}/#{provider_name}/callback"
) )
verify_scopes
handle_response handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e rescue StandardError => e
handle_error(e) ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end end
private private
@@ -63,22 +64,14 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError raise NotImplementedError
end end
def verify_scopes def cache_key
true "#{provider_name}::#{users_data['email'].downcase}"
end
def failure_redirect_url
'/'
end
def handle_error(exception)
ChatwootExceptionTracker.new(exception).capture_exception
redirect_to failure_redirect_url
end end
def create_channel_with_inbox def create_channel_with_inbox
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account) channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!( account.inboxes.create!(
account: account, account: account,
channel: channel_email, channel: channel_email,
@@ -93,14 +86,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0] decoded_token[0]
end end
def account_from_signed_id def account_id
raise 'Missing state variable' if params[:state].blank? ::Redis::Alfred.get(cache_key)
GlobalID::Locator.locate_signed(params[:state])
end end
def account def account
@account ||= account_from_signed_id @account ||= Account.find(account_id)
end end
# Fallback name, for when name field is missing from users_data # Fallback name, for when name field is missing from users_data
@@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior # Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated. # For example, you may want to send an email after a foo is updated.
# #
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create def create
resource = resource_class.new(resource_params) resource = resource_class.new(resource_params)
authorize_resource(resource) authorize_resource(resource)
+5
View File
@@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL); return axios.get(requestURL);
} }
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last // eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) { filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -134,13 +134,13 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`); return axios.get(`${this.url}/${conversationId}/attachments`);
} }
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) { getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`); return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
} }
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
}
} }
export default new ConversationApi(); export default new ConversationApi();
@@ -17,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) }, additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' }, phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' }, thumbnail: { type: String, default: '' },
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 },
}); });
@@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template> <template>
<CardLayout :key="id" layout="row"> <CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full /> <Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1"> <div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-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"> <span class="text-base font-medium truncate text-n-slate-12">
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue'; import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({ defineProps({
showSearch: { showSearch: { type: Boolean, default: true },
type: Boolean, searchValue: { type: String, default: '' },
default: true, headerTitle: { type: String, required: true },
}, buttonLabel: { type: String, default: '' },
searchValue: { activeSort: { type: String, default: 'last_activity_at' },
type: String, activeOrdering: { type: String, default: '' },
default: '', isSegmentsView: { type: Boolean, default: false },
}, hasActiveFilters: { type: Boolean, default: false },
headerTitle: { isLabelView: { type: Boolean, default: false },
type: String, isActiveView: { type: Boolean, default: false },
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -85,7 +59,7 @@ const emit = defineEmits([
</Input> </Input>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative"> <div v-if="!isLabelView && !isActiveView" class="relative">
<Button <Button
id="toggleContactsFilterButton" id="toggleContactsFilterButton"
:icon=" :icon="
@@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" /> <slot name="filter" />
</div> </div>
<Button <Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView" v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save" icon="i-lucide-save"
color="slate" color="slate"
size="sm" size="sm"
@@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')" @click="emit('createSegment')"
/> />
<Button <Button
v-if="isSegmentsView && !isLabelView" v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash" icon="i-lucide-trash"
color="slate" color="slate"
size="sm" size="sm"
@@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null }, activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false }, hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false }, isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
}); });
const emit = defineEmits([ const emit = defineEmits([
@@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle" :header-title="headerTitle"
:is-segments-view="hasActiveSegments" :is-segments-view="hasActiveSegments"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters" :has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')" :button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)" @search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue'; import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({ const props = defineProps({
searchValue: { type: String, default: '' }, searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' }, headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index'; return route.name !== 'contacts_dashboard_segments_index';
}); });
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed( const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index' () => route.name === 'contacts_dashboard_labels_index'
); );
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
}; };
@@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300"> <div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper <ContactListHeaderWrapper
ref="contactListHeaderWrapper" ref="contactListHeaderWrapper"
:show-search="isNotSegmentView" :show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue" :search-value="searchValue"
:active-sort="activeSort" :active-sort="activeSort"
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@@ -66,6 +79,7 @@ const openFilter = () => {
:segments-id="segmentsId" :segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters" :has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView" :is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@search="emit('search', $event)" @search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)" @apply-filter="emit('applyFilter', $event)"
@@ -74,11 +88,7 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto"> <main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]"> <div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview <ContactsActiveFiltersPreview
v-if=" v-if="showActiveFiltersPreview"
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment" :active-segment="activeSegment"
@clear-filters="emit('clearFilters')" @clear-filters="emit('clearFilters')"
@open-filter="openFilter" @open-filter="openFilter"
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail" :thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber" :phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes" :additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id" :is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating" :is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)" @toggle="toggleExpanded(contact.id)"
@@ -56,7 +56,7 @@ useKeyboardEvents(keyboardEvents);
<template> <template>
<div <div
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1" 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')"
@@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue'; import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: { type: String, default: '' },
type: String, label: { type: String, default: '' },
default: '', placeholder: { type: String, default: '' },
}, focusOnMount: { type: Boolean, default: false },
label: { maxLength: { type: Number, default: 200 },
type: String, showCharacterCount: { type: Boolean, default: true },
default: '', disabled: { type: Boolean, default: false },
}, message: { type: String, default: '' },
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: { messageType: {
type: String, type: String,
default: 'info', default: 'info',
@@ -43,6 +19,7 @@ const props = defineProps({
}, },
enableVariables: { type: Boolean, default: false }, enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true }, enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
}); });
const emit = defineEmits(['update:modelValue']); const emit = defineEmits(['update:modelValue']);
@@ -120,6 +97,7 @@ watch(
:disabled="disabled" :disabled="disabled"
:enable-variables="enableVariables" :enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses" :enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput" @input="handleInput"
@focus="handleFocus" @focus="handleFocus"
@blur="handleBlur" @blur="handleBlur"
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template> <template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background"> <section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0"> <header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto"> <div class="w-full max-w-[60rem] mx-auto">
<div <div
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"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div> </div>
</div> </div>
</header> </header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4"> <div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" /> <slot v-if="!showPaywall" name="controls" />
<div <div
@@ -1,6 +1,5 @@
<script setup> <script setup>
import { nextTick, ref, watch } from 'vue'; import { nextTick, ref, watch, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useTrack } from 'dashboard/composables'; import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -9,23 +8,17 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue'; import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue'; import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue'; import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue'; import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import CopilotEmptyState from './CopilotEmptyState.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue'; import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({ const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: { messages: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -44,18 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n(); const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => { const sendMessage = message => {
emit('sendMessage', message); emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE); useTrack(COPILOT_EVENTS.SEND_MESSAGE);
}; };
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const chatContainer = ref(null); const chatContainer = ref(null);
const scrollToBottom = async () => { const scrollToBottom = async () => {
@@ -65,20 +51,40 @@ const scrollToBottom = async () => {
} }
}; };
const promptOptions = [ const groupedMessages = computed(() => {
{ const result = [];
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL', let thinkingGroup = [];
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT', props.messages.forEach(message => {
}, if (message.message_type === 'assistant_thinking') {
{ thinkingGroup.push(message);
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL', } else {
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT', if (thinkingGroup.length > 0) {
}, result.push({
{ id: thinkingGroup[0].id,
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL', message_type: 'thinking_group',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT', messages: thinkingGroup,
}, });
]; thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings(); const { updateUISettings } = useUISettings();
@@ -95,8 +101,22 @@ const handleSidebarAction = action => {
} }
}; };
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch( watch(
[() => props.messages, () => props.isCaptainTyping], [() => props.messages],
() => { () => {
scrollToBottom(); scrollToBottom();
}, },
@@ -108,64 +128,57 @@ watch(
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full"> <div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader <SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')" :title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="[ :buttons="copilotButtons"
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: $t('CAPTAIN.COPILOT.RESET'),
},
]"
@click="handleSidebarAction" @click="handleSidebarAction"
@close="closeCopilotPanel" @close="closeCopilotPanel"
/> />
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div <div
v-if="!messages.length" ref="chatContainer"
class="h-full w-full flex items-center justify-center" class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
> >
<div class="h-fit px-3 py-3 space-y-1"> <div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
<span class="text-xs text-n-slate-10"> <template v-for="(item, index) in groupedMessages" :key="item.id">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }} <CopilotAgentMessage
</span> v-if="item.message_type === 'user'"
<button :message="item.message"
v-for="prompt in promptOptions" />
:key="prompt.label" <CopilotAssistantMessage
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1" v-else-if="item.message_type === 'assistant'"
@click="() => useSuggestion(prompt)" :message="item.message"
> :is-last-message="index === groupedMessages.length - 1"
<span>{{ t(prompt.label) }}</span> :conversation-inbox-type="conversationInboxType"
<Icon icon="i-lucide-chevron-right" /> />
</button> <CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div> </div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div> </div>
<div class="mx-3 mt-px mb-2"> <div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1"> <div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant <ToggleCopilotAssistant
v-if="assistants.length" v-if="assistants.length > 1"
:assistants="assistants" :assistants="assistants"
:active-assistant="activeAssistant" :active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)" @set-assistant="$event => emit('setAssistant', $event)"
/> />
<div v-else /> <div v-else />
</div> </div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" /> <CopilotInput
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({ const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: { message: {
type: Object, type: Object,
required: true, required: true,
@@ -20,6 +24,15 @@ const props = defineProps({
required: true, required: true,
}, },
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => { const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content); const formatter = new MessageFormatter(props.message.content);
@@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
); );
}); });
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => { const useCopilotResponse = () => {
if (insertIntoRichEditor.value) { if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content); emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -57,7 +68,7 @@ const useCopilotResponse = () => {
/> />
<div class="flex flex-row mt-1"> <div class="flex flex-row mt-1">
<Button <Button
v-if="!hasEmptyMessageContent" v-if="showUseButton"
:label="$t('CAPTAIN.COPILOT.USE')" :label="$t('CAPTAIN.COPILOT.USE')"
faded faded
sm sm
@@ -0,0 +1,106 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles" icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9" class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/> />
<div class="text-sm text-n-slate-11"> <div class="text-sm text-n-slate-12">
{{ content }} {{ content }}
</div> </div>
</div> </div>
@@ -51,10 +51,10 @@ watch(
}" }"
> >
<CopilotThinkingBlock <CopilotThinkingBlock
v-for="message in messages" v-for="copilotMessage in messages"
:key="message.id" :key="copilotMessage.id"
:content="message.content" :content="copilotMessage.message.content"
:reasoning="message.reasoning" :reasoning="copilotMessage.message.reasoning"
/> />
</div> </div>
</div> </div>
@@ -74,6 +74,7 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle"> <slot name="trigger" :toggle="toggle">
<Button <Button
ref="triggerRef" ref="triggerRef"
type="button"
sm sm
slate slate
:variant :variant
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({ const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm sm
slate slate
faded faded
type="button"
:icon="selectedItem.icon" :icon="selectedItem.icon"
:label="selectedItem.name" :label="selectedItem.name"
@click="toggle" @click="toggle"
/> />
<Button v-else sm slate faded @click="toggle"> <Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon> <template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" /> <Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template> </template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span> <span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-56 z-50" strong> <DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm" v-model="searchTerm"
autofocus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">
@@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, watch } from 'vue';
import Input from './Input.vue'; import Input from './Input.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null }); const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = { const convertToMinutes = newValue => {
MINUTES: 'minutes', if (unit.value === DURATION_UNITS.MINUTES) {
HOURS: 'hours', return Math.floor(newValue);
DAYS: 'days', }
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
}; };
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60); if (unit.value === DURATION_UNITS.HOURS)
if (unit.value === UNIT_TYPES.DAYS) return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60); return Math.floor(duration.value / 24 / 60);
return 0; return 0;
}, },
set(newValue) { set(newValue) {
let minuteValue; let minuteValue = convertToMinutes(newValue);
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
}, },
}); });
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script> </script>
<template> <template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled" :disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40" class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
> >
<option :value="UNIT_TYPES.MINUTES"> <option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }} {{ t('DURATION_INPUT.MINUTES') }}
</option> </option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option> <option :value="DURATION_UNITS.HOURS">
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option> {{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select> </select>
</template> </template>
@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -109,49 +109,58 @@ const downloadAudio = async () => {
</audio> </audio>
<div <div
v-bind="$attrs" v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]" class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
> >
<button class="p-0 border-0 size-8" @click="playOrPause"> <div class="flex gap-1 w-full flex-1 items-center justify-start">
<Icon <button class="p-0 border-0 size-8" @click="playOrPause">
v-if="isPlaying" <Icon
class="size-8" v-if="isPlaying"
icon="i-teenyicons-pause-small-solid" class="size-8"
/> icon="i-teenyicons-pause-small-solid"
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" /> />
</button> <Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
<div class="tabular-nums text-xs"> </button>
{{ formatTime(currentTime) }} / {{ formatTime(duration) }} <div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
<div class="flex-1 items-center flex px-2">
<input <div
type="range" v-if="attachment.transcribedText"
min="0" class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
:max="duration" >
:value="currentTime" {{ attachment.transcribedText }}
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div> </div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div> </div>
</template> </template>
@@ -235,6 +235,12 @@ const menuItems = computed(() => {
), ),
activeOn: ['contacts_dashboard_index', 'contacts_edit'], activeOn: ['contacts_dashboard_index', 'contacts_edit'],
}, },
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{ {
name: 'Segments', name: 'Segments',
icon: 'i-lucide-group', icon: 'i-lucide-group',
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue'; import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue'; import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const route = useRoute();
const store = useStore(); const store = useStore();
const conversationListRef = ref(null); const conversationListRef = ref(null);
@@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() {
} }
} }
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) { async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', { store.dispatch('setCurrentChatPriority', {
priority, priority,
@@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', { await store.dispatch('markMessagesUnread', {
id: conversationId, id: conversationId,
}); });
const { redirectToConversationList();
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
// Ignore error // Ignore error
} }
} }
async function onAssignTeam(team, conversationId = null) { async function onAssignTeam(team, conversationId = null) {
try { try {
await store.dispatch('assignTeam', { await store.dispatch('assignTeam', {
@@ -764,6 +772,26 @@ onMounted(() => {
} }
}); });
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation); provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation); provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent); provide('assignAgent', onAssignAgent);
@@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead); provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority); provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected); provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData()); watch(activeTeam, () => resetAndFetchData());
@@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => {
</template> </template>
</DynamicScroller> </DynamicScroller>
</div> </div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection <TeleportWithDirection
v-if="showAdvancedFilters" v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget" to="#conversationFilterTeleportTarget"
@@ -16,6 +16,7 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'isConversationSelected', 'isConversationSelected',
'deleteConversation',
], ],
props: { props: {
source: { source: {
@@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</template> </template>
@@ -134,7 +134,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">
<div <div
class="rounded-lg shadow outline-1 outline" class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'" :class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
> >
<Button <Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div <div
v-if="showActionsDropdown" v-if="showActionsDropdown"
v-on-clickaway="closeDropdown" v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]" class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]"
> >
<WootDropdownMenu class="mb-0"> <WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending"> <WootDropdownItem v-if="!isPending">
@@ -1,16 +1,11 @@
<script setup> <script setup>
import { ref, computed, onMounted, watchEffect } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store'; import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue'; import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store'; import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({ defineProps({
conversationId: {
type: [Number, String],
required: true,
},
conversationInboxType: { conversationInboxType: {
type: String, type: String,
required: true, required: true,
@@ -20,12 +15,24 @@ const props = defineProps({
const store = useStore(); const store = useStore();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords'); const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant'); const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings(); const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null); const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => { const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id; const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
}); });
}; };
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => { const handleReset = () => {
messages.value = []; selectedCopilotThreadId.value = null;
}; };
const sendMessage = async message => { const sendMessage = async message => {
// Add user message if (selectedCopilotThreadId.value) {
messages.value.push({ await store.dispatch('copilotMessages/create', {
id: messages.value.length + 1, assistant_id: activeAssistant.value.id,
role: 'user', conversation_id: currentChat.value?.id,
content: message, threadId: selectedCopilotThreadId.value,
}); message,
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
}); });
} catch (error) { } else {
// eslint-disable-next-line const response = await store.dispatch('copilotThreads/create', {
console.log(error); assistant_id: activeAssistant.value.id,
} finally { conversation_id: currentChat.value?.id,
isCaptainTyping.value = false; message,
});
selectedCopilotThreadId.value = response.id;
} }
}; };
onMounted(() => { onMounted(() => {
store.dispatch('captainAssistants/get'); store.dispatch('captainAssistants/get');
}); });
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script> </script>
<template> <template>
<Copilot <div
:messages="messages" v-if="shouldShowCopilotPanel"
:support-agent="currentUser" class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
:is-captain-typing="isCaptainTyping" >
:conversation-inbox-type="conversationInboxType" <Copilot
:assistants="assistants" :messages="messages"
:active-assistant="activeAssistant" :support-agent="currentUser"
@set-assistant="setAssistant" :conversation-inbox-type="conversationInboxType"
@send-message="sendMessage" :assistants="assistants"
@reset="handleReset" :active-assistant="activeAssistant"
/> @set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template> </template>
@@ -9,6 +9,7 @@ const contacts = accountId => ({
'contacts_edit', 'contacts_edit',
'contacts_edit_segment', 'contacts_edit_segment',
'contacts_edit_label', 'contacts_edit_label',
'contacts_dashboard_active',
], ],
menuItems: [ menuItems: [
{ {
@@ -18,6 +19,13 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index', toStateName: 'contacts_dashboard_index',
}, },
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
], ],
}); });
@@ -78,6 +78,7 @@ export default {
'markAsRead', 'markAsRead',
'assignPriority', 'assignPriority',
'updateConversationStatus', 'updateConversationStatus',
'deleteConversation',
], ],
data() { data() {
return { return {
@@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id); this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu(); this.closeContextMenu();
}, },
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
}, },
}; };
</script> </script>
@@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread" @mark-as-unread="markAsUnread"
@mark-as-read="markAsRead" @mark-as-read="markAsRead"
@assign-priority="assignPriority" @assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/> />
</ContextMenu> </ContextMenu>
</div> </div>
@@ -107,10 +107,10 @@ const isLinearFeatureEnabled = computed(() =>
<template> <template>
<div <div
ref="conversationHeader" ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12" class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
> >
<div <div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0" class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
> >
<BackButton <BackButton
v-if="showBackButton" v-if="showBackButton"
@@ -122,11 +122,12 @@ const isLinearFeatureEnabled = computed(() =>
:username="currentContact.name" :username="currentContact.name"
:status="currentContact.availability_status" :status="currentContact.availability_status"
size="32px" size="32px"
class="flex-shrink-0"
/> />
<div <div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit" class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
> >
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit"> <div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span <span
class="text-sm font-medium truncate leading-tight text-n-slate-12" class="text-sm font-medium truncate leading-tight text-n-slate-12"
> >
@@ -136,7 +137,7 @@ const isLinearFeatureEnabled = computed(() =>
v-if="!isHMACVerified" v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14" size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]" class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning" icon="warning"
/> />
</div> </div>
@@ -152,20 +153,20 @@ const isLinearFeatureEnabled = computed(() =>
</div> </div>
</div> </div>
<div <div
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0" class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
> >
<SLACardLabel <SLACardLabel
v-if="hasSlaPolicyId" v-if="hasSlaPolicyId"
:chat="chat" :chat="chat"
show-extended-info show-extended-info
:parent-width="width" :parent-width="width"
class="hidden lg:flex" class="hidden md:flex"
/> />
<Linear <Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled" v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:parent-width="width" :parent-width="width"
class="hidden lg:flex" class="hidden md:flex"
/> />
<MoreActions :conversation-id="currentChat.id" /> <MoreActions :conversation-id="currentChat.id" />
</div> </div>
@@ -1,43 +1,23 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue'; import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({ defineProps({
currentChat: { currentChat: {
required: true, required: true,
type: Object, type: Object,
}, },
}); });
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings(); const { uiSettings } = useUISettings();
const activeTab = computed(() => { const activeTab = computed(() => {
const { const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = uiSettings.value;
if (isContactSidebarOpen) { if (isContactSidebarOpen) {
return 0; return 0;
} }
if (isCopilotPanelOpen) {
return 1;
}
return null; return null;
}); });
</script> </script>
@@ -52,13 +32,6 @@ const activeTab = computed(() => {
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id" :inbox-id="currentChat.inbox_id"
/> />
<CopilotContainer
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -134,7 +134,7 @@ onUnmounted(() => {
<SLAPopoverCard <SLAPopoverCard
v-if="showSlaPopoverCard" v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents" :sla-missed-events="slaEvents"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex" class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/> />
</div> </div>
</template> </template>
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default { export default {
components: { components: {
@@ -45,7 +46,14 @@ export default {
'assignAgent', 'assignAgent',
'assignTeam', 'assignTeam',
'assignLabel', 'assignLabel',
'deleteConversation',
], ],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() { data() {
return { return {
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -121,6 +129,11 @@ export default {
icon: 'people-team-add', icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
}, },
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
}; };
}, },
computed: { computed: {
@@ -178,6 +191,9 @@ export default {
assignPriority(priority) { assignPriority(priority) {
this.$emit('assignPriority', priority); this.$emit('assignPriority', priority);
}, },
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) { show(key) {
// If the conversation status is same as the action, then don't display the option // If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved. // i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)" @click.stop="$emit('assignTeam', team)"
/> />
</MenuItemWithSubmenu> </MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div> </div>
</template> </template>
@@ -142,7 +142,7 @@ onMounted(() => {
v-if="linkedIssue" v-if="linkedIssue"
:issue="linkedIssue.issue" :issue="linkedIssue.issue"
:link-id="linkedIssue.id" :link-id="linkedIssue.id"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible" class="absolute start-0 xl:start-auto xl:end-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue" @unlink-issue="unlinkIssue"
/> />
<woot-modal <woot-modal
@@ -45,9 +45,10 @@ export function useAccount() {
}; };
}; };
const updateAccount = async data => { const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', { await store.dispatch('accounts/update', {
...data, ...data,
options,
}); });
}; };
@@ -33,6 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code', 'code',
]; ];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [ export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{ {
name: 'Small', name: 'Small',
@@ -36,6 +36,7 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`, 'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`, 'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
}; };
function extractAttrChange(attrChange) { function extractAttrChange(attrChange) {
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase(); const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase(); const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') { if (auditableType === 'accountuser') {
translationPayload = handleAccountUser( translationPayload = handleAccountUser(
auditLogItem, auditLogItem,
@@ -69,6 +69,9 @@
}, },
"ACCOUNT": { "ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})" "EDIT": "{agentName} updated the account configuration (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
} }
} }
} }
@@ -286,6 +286,7 @@
"HEADER": { "HEADER": {
"TITLE": "Contacts", "TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts", "SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...", "SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message", "MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message", "SEND_MESSAGE": "Send message",
@@ -560,7 +561,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below", "SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact", "BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
} }
}, },
@@ -118,6 +118,11 @@
"FAILED": "Couldn't change priority. Please try again." "FAILED": "Couldn't change priority. Please try again."
} }
}, },
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": { "CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending", "PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved", "RESOLVED": "Mark as resolved",
@@ -134,6 +139,7 @@
"ASSIGN_LABEL": "Assign label", "ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...", "AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team", "ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"API": { "API": {
"AGENT_ASSIGNMENT": { "AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -208,6 +214,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed", "ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed", "CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later", "MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:", "SENT_BY": "Sent by:",
@@ -46,7 +46,31 @@
}, },
"AUTO_RESOLVE": { "AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations", "TITLE": "Auto-resolve conversations",
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below." "NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agent’s reply"
},
"UPDATE_BUTTON": "Save Changes"
}, },
"NAME": { "NAME": {
"LABEL": "Account name", "LABEL": "Account name",
@@ -70,7 +94,15 @@
}, },
"AUTO_RESOLVE_IGNORE_WAITING": { "AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations", "LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent’s reply." "HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Transcribe Audio Messages",
"NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
"API": {
"SUCCESS": "Audio transcription setting updated successfully",
"ERROR": "Failed to update audio transcription setting"
}
}, },
"AUTO_RESOLVE_DURATION": { "AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution", "LABEL": "Inactivity duration for resolution",
@@ -328,6 +328,9 @@
"HEADER_KNOW_MORE": "Know more", "HEADER_KNOW_MORE": "Know more",
"COPILOT": { "COPILOT": {
"TITLE": "Copilot", "TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilot’s here to speed things up.",
"SEND_MESSAGE": "Send message...", "SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.", "EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking", "LOADER": "Captain is thinking",
@@ -348,6 +351,14 @@
"RATE": { "RATE": {
"LABEL": "Rate this conversation", "LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness." "CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
} }
} }
}, },
@@ -381,6 +392,7 @@
}, },
"ASSISTANTS": { "ASSISTANTS": {
"HEADER": "Assistants", "HEADER": "Assistants",
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
"ADD_NEW": "Create a new assistant", "ADD_NEW": "Create a new assistant",
"DELETE": { "DELETE": {
"TITLE": "Are you sure to delete the assistant?", "TITLE": "Are you sure to delete the assistant?",
@@ -286,6 +286,7 @@
"REPORTS": "Reports", "REPORTS": "Reports",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"CONTACTS": "Contacts", "CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain", "CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_DOCUMENTS": "Documents",
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
config: { config: {
@@ -8,6 +9,8 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const isDefaultScreen = computed(() => { const isDefaultScreen = computed(() => {
return ( return (
props.config.isDefaultScreen && props.config.isDefaultScreen &&
@@ -53,12 +56,13 @@ const isDefaultScreen = computed(() => {
</div> </div>
</div> </div>
<div v-if="isDefaultScreen" class="overflow-auto max-h-60"> <div v-if="isDefaultScreen" class="overflow-auto max-h-60">
<h2 class="mb-2 text-2xl break-words text-slate-900 dark:text-white"> <h2 class="mb-2 text-2xl break-words text-n-slate-12">
{{ config.welcomeHeading }} {{ config.welcomeHeading }}
</h2> </h2>
<p class="text-sm break-words text-slate-600 dark:text-slate-100"> <p
{{ config.welcomeTagline }} v-dompurify-html="formatMessage(config.welcomeTagline)"
</p> class="text-sm break-words text-n-slate-11 [&_a]:!text-n-slate-11 [&_a]:underline"
/>
</div> </div>
</div> </div>
</div> </div>
@@ -25,6 +25,8 @@ const Sidebar = defineAsyncComponent(
() => import('../../components/layout/Sidebar.vue') () => import('../../components/layout/Sidebar.vue')
); );
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import CopilotLauncher from 'dashboard/components-next/copilot/CopilotLauncher.vue';
import CopilotContainer from 'dashboard/components/copilot/CopilotContainer.vue';
export default { export default {
components: { components: {
@@ -37,6 +39,8 @@ export default {
AddLabelModal, AddLabelModal,
NotificationPanel, NotificationPanel,
UpgradePage, UpgradePage,
CopilotLauncher,
CopilotContainer,
}, },
setup() { setup() {
const upgradePageRef = ref(null); const upgradePageRef = ref(null);
@@ -219,6 +223,9 @@ export default {
<template v-if="!showUpgradePage"> <template v-if="!showUpgradePage">
<router-view /> <router-view />
<CommandBar /> <CommandBar />
<CopilotLauncher />
<CopilotContainer />
<NotificationPanel <NotificationPanel
v-if="isNotificationPanel" v-if="isNotificationPanel"
@close="closeNotificationPanel" @close="closeNotificationPanel"
@@ -16,7 +16,7 @@ onMounted(() => {
<template> <template>
<div <div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background" class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
> >
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive"> <keep-alive v-if="keepAlive">
@@ -67,9 +67,11 @@ const hasContacts = computed(() => contacts.value.length > 0);
const isContactIndexView = computed( const isContactIndexView = computed(
() => route.name === 'contacts_dashboard_index' && pageNumber.value === 1 () => route.name === 'contacts_dashboard_index' && pageNumber.value === 1
); );
const isActiveView = computed(() => route.name === 'contacts_dashboard_active');
const hasAppliedFilters = computed(() => { const hasAppliedFilters = computed(() => {
return appliedFilters.value.length > 0; return appliedFilters.value.length > 0;
}); });
const showEmptyStateLayout = computed(() => { const showEmptyStateLayout = computed(() => {
return ( return (
!searchQuery.value && !searchQuery.value &&
@@ -89,11 +91,20 @@ const showEmptyText = computed(() => {
const headerTitle = computed(() => { const headerTitle = computed(() => {
if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE'); if (searchQuery.value) return t('CONTACTS_LAYOUT.HEADER.SEARCH_TITLE');
if (isActiveView.value) return t('CONTACTS_LAYOUT.HEADER.ACTIVE_TITLE');
if (activeSegmentId.value) return activeSegment.value?.name; if (activeSegmentId.value) return activeSegment.value?.name;
if (activeLabel.value) return `#${activeLabel.value}`; if (activeLabel.value) return `#${activeLabel.value}`;
return t('CONTACTS_LAYOUT.HEADER.TITLE'); return t('CONTACTS_LAYOUT.HEADER.TITLE');
}); });
const emptyStateMessage = computed(() => {
if (isActiveView.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.ACTIVE_EMPTY_STATE_TITLE');
if (!searchQuery.value || hasAppliedFilters.value)
return t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE');
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
});
const updatePageParam = (page, search = '') => { const updatePageParam = (page, search = '') => {
const query = { const query = {
...route.query, ...route.query,
@@ -132,6 +143,15 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page); updatePageParam(page);
}; };
const fetchActiveContacts = async (page = 1) => {
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
sortAttr: buildSortAttr(),
});
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1) => { const searchContacts = debounce(async (value, page = 1) => {
await store.dispatch('contacts/clearContactFilters'); await store.dispatch('contacts/clearContactFilters');
searchValue.value = value; searchValue.value = value;
@@ -158,6 +178,11 @@ const fetchContactsBasedOnContext = async page => {
} }
// Reset the search value when we change the view // Reset the search value when we change the view
searchValue.value = ''; searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
return;
}
// If there are applied filters or active segment with query // If there are applied filters or active segment with query
if ( if (
(hasAppliedFilters.value || activeSegment.value?.query) && (hasAppliedFilters.value || activeSegment.value?.query) &&
@@ -184,6 +209,11 @@ const handleSort = async ({ sort, order }) => {
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts();
return;
}
await (activeSegmentId.value || hasAppliedFilters.value await (activeSegmentId.value || hasAppliedFilters.value
? fetchSavedOrAppliedFilteredContact( ? fetchSavedOrAppliedFilteredContact(
activeSegmentId.value activeSegmentId.value
@@ -210,7 +240,7 @@ watch(
); );
watch( watch(
[activeLabel, activeSegment], [activeLabel, activeSegment, isActiveView],
() => { () => {
fetchContactsBasedOnContext(pageNumber.value); fetchContactsBasedOnContext(pageNumber.value);
}, },
@@ -222,6 +252,13 @@ watch(searchQuery, value => {
searchValue.value = value || ''; searchValue.value = value || '';
// Reset the view if there is search query when we click on the sidebar group // Reset the view if there is search query when we click on the sidebar group
if (value === undefined) { if (value === undefined) {
if (
isActiveView.value ||
activeLabel.value ||
activeSegment.value ||
hasAppliedFilters.value
)
return;
fetchContacts(); fetchContacts();
} }
}); });
@@ -232,6 +269,10 @@ onMounted(async () => {
await searchContacts(searchQuery.value, pageNumber.value); await searchContacts(searchQuery.value, pageNumber.value);
return; return;
} }
if (isActiveView.value) {
await fetchActiveContacts(pageNumber.value);
return;
}
await fetchContacts(pageNumber.value); await fetchContacts(pageNumber.value);
} else if (activeSegment.value && activeSegmentId.value) { } else if (activeSegment.value && activeSegmentId.value) {
await fetchSavedOrAppliedFilteredContact( await fetchSavedOrAppliedFilteredContact(
@@ -286,11 +327,7 @@ onMounted(async () => {
class="flex items-center justify-center py-10" class="flex items-center justify-center py-10"
> >
<span class="text-base text-n-slate-11"> <span class="text-base text-n-slate-11">
{{ {{ emptyStateMessage }}
searchQuery || !hasAppliedFilters
? t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE')
: t('CONTACTS_LAYOUT.EMPTY_STATE.LIST_EMPTY_STATE_TITLE')
}}
</span> </span>
</div> </div>
@@ -32,6 +32,12 @@ export const routes = [
component: ContactsIndex, component: ContactsIndex,
meta: commonMeta, meta: commonMeta,
}, },
{
path: 'active',
name: 'contacts_dashboard_active',
component: ContactsIndex,
meta: commonMeta,
},
], ],
}, },
{ {
@@ -97,11 +97,8 @@ export default {
return false; return false;
} }
const { const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
is_contact_sidebar_open: isContactSidebarOpen, return isContactSidebarOpen;
is_copilot_panel_open: isCopilotPanelOpen,
} = this.uiSettings;
return isContactSidebarOpen || isCopilotPanelOpen;
}, },
showPopOverSearch() { showPopOverSearch() {
return !this.isFeatureEnabledonAccount( return !this.isFeatureEnabledonAccount(
@@ -208,7 +205,7 @@ export default {
</script> </script>
<template> <template>
<section class="flex w-full h-full"> <section class="flex w-full h-full min-w-0">
<ChatList <ChatList
:show-conversation-list="showConversationList" :show-conversation-list="showConversationList"
:conversation-inbox="inboxId" :conversation-inbox="inboxId"
@@ -183,7 +183,6 @@ export default {
</div> </div>
<div v-else class="flex flex-col w-full h-full"> <div v-else class="flex flex-col w-full h-full">
<InboxItemHeader <InboxItemHeader
class="flex-1"
:total-length="totalNotificationCount" :total-length="totalNotificationCount"
:current-index="activeNotificationIndex" :current-index="activeNotificationIndex"
:active-notification="activeNotification" :active-notification="activeNotification"
@@ -109,7 +109,7 @@ export default {
<template> <template>
<div <div
class="flex items-center justify-between w-full gap-2 py-2 border-b ltr:pl-4 rtl:pl-2 h-14 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak" class="flex items-center justify-between w-full gap-2 border-b ltr:pl-4 rtl:pl-2 h-12 ltr:pr-2 rtl:pr-4 rtl:border-r border-n-weak"
> >
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<BackButton <BackButton
@@ -16,6 +16,7 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue'; import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue'; import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue'; import AutoResolve from './components/AutoResolve.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import SectionLayout from './components/SectionLayout.vue'; import SectionLayout from './components/SectionLayout.vue';
export default { export default {
@@ -26,6 +27,7 @@ export default {
BuildInfo, BuildInfo,
AccountDelete, AccountDelete,
AutoResolve, AutoResolve,
AudioTranscription,
SectionLayout, SectionLayout,
WithLabel, WithLabel,
NextInput, NextInput,
@@ -235,6 +237,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" /> <woot-loading-state v-if="uiFlags.isFetchingItem" />
</div> </div>
<AutoResolve v-if="showAutoResolutionConfig" /> <AutoResolve v-if="showAutoResolutionConfig" />
<AudioTranscription v-if="isOnChatwootCloud" />
<AccountId /> <AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud"> <div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete /> <AccountDelete />
@@ -0,0 +1,51 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { audio_transcriptions } = currentAccount.value?.settings || {};
isEnabled.value = !!audio_transcriptions;
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.API.ERROR'));
}
};
const toggleAudioTranscription = async () => {
return updateAccountSettings({
audio_transcriptions: isEnabled.value,
});
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUDIO_TRANSCRIPTION.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAudioTranscription" />
</div>
</template>
</SectionLayout>
</template>
@@ -1,35 +1,78 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { h, ref, watch, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue'; import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue'; import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue'; import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue'; import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n(); const { t } = useI18n();
const duration = ref(0); const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref(''); const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false); const ignoreWaiting = ref(false);
const isEnabled = ref(false); const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount(); const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch( watch(
currentAccount, [currentAccount, labelOptions],
() => { () => {
const { const {
auto_resolve_after, auto_resolve_after,
auto_resolve_message, auto_resolve_message,
auto_resolve_ignore_waiting, auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {}; } = currentAccount.value?.settings || {};
duration.value = auto_resolve_after; duration.value = auto_resolve_after;
message.value = auto_resolve_message; message.value = auto_resolve_message;
ignoreWaiting.value = auto_resolve_ignore_waiting; ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) { if (duration.value) {
isEnabled.value = true; isEnabled.value = true;
@@ -40,16 +83,19 @@ watch(
const updateAccountSettings = async settings => { const updateAccountSettings = async settings => {
try { try {
await updateAccount(settings); isSubmitting.value = true;
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS')); await updateAccount(settings, { silent: true });
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) { } catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
} }
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (duration.value < 10) { if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
return Promise.resolve(); return Promise.resolve();
} }
@@ -57,6 +103,7 @@ const handleSubmit = async () => {
auto_resolve_after: duration.value, auto_resolve_after: duration.value,
auto_resolve_message: message.value, auto_resolve_message: message.value,
auto_resolve_ignore_waiting: ignoreWaiting.value, auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
}); });
}; };
@@ -68,6 +115,7 @@ const handleDisable = async () => {
auto_resolve_after: null, auto_resolve_after: null,
auto_resolve_message: '', auto_resolve_message: '',
auto_resolve_ignore_waiting: false, auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
}); });
}; };
@@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
<SectionLayout <SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')" :title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')" :description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border with-border
> >
<template #headerActions> <template #headerActions>
@@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
<form class="grid gap-5" @submit.prevent="handleSubmit"> <form class="grid gap-5" @submit.prevent="handleSubmit">
<WithLabel <WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')" :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.HELP')"
> >
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]"> <div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days --> <!-- allow 10 mins to 999 days -->
<DurationInput <DurationInput
v-model="duration" v-model="duration"
v-model:unit="unit"
min="0" min="0"
max="1439856" max="1438560"
class="w-full" class="w-full"
/> />
</div> </div>
</WithLabel> </WithLabel>
<WithLabel <WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')" :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message=" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
> >
<TextArea <TextArea
v-model="message" v-model="message"
class="w-full" class="w-full"
:placeholder=" :placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER') t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
" "
/> />
</WithLabel> </WithLabel>
<WithLabel <WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')" <div
> class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
<template #rightOfLabel> >
<Switch v-model="ignoreWaiting" /> <div class="p-3 h-12 flex items-center justify-between">
</template> <span>
<p class="text-sm ml-px text-n-slate-10 max-w-lg"> {{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }} </span>
</p> <Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel> </WithLabel>
<div class="flex gap-2"> <div class="flex gap-2">
<NextButton <NextButton
blue blue
type="submit" type="submit"
:label=" :is-loading="isSubmitting"
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON') :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
"
/> />
</div> </div>
</form> </form>
@@ -1,24 +1,19 @@
<script setup> <script setup>
defineProps({ defineProps({
title: { title: { type: String, required: true },
type: String, description: { type: String, required: true },
required: true, withBorder: { type: Boolean, default: false },
}, hideContent: { type: Boolean, default: false },
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
}); });
</script> </script>
<template> <template>
<section <section
class="grid grid-cols-1 py-8 gap-8" class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
:class="{ 'border-t border-n-weak': withBorder }" :class="{
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
> >
<header class="grid grid-cols-4"> <header class="grid grid-cols-4">
<div class="col-span-3"> <div class="col-span-3">
@@ -33,7 +28,10 @@ defineProps({
<slot name="headerActions" /> <slot name="headerActions" />
</div> </div>
</header> </header>
<div class="text-n-slate-12"> <div
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot /> <slot />
</div> </div>
</section> </section>
@@ -23,6 +23,8 @@ import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox'; import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
@@ -43,6 +45,7 @@ export default {
NextButton, NextButton,
InstagramReauthorize, InstagramReauthorize,
DuplicateInboxBanner, DuplicateInboxBanner,
Editor,
}, },
mixins: [inboxMixin], mixins: [inboxMixin],
setup() { setup() {
@@ -70,6 +73,7 @@ export default {
selectedTabIndex: 0, selectedTabIndex: 0,
selectedPortalSlug: '', selectedPortalSlug: '',
showBusinessNameInput: false, showBusinessNameInput: false,
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -480,10 +484,10 @@ export default {
" "
/> />
<woot-input <Editor
v-if="isAWebWidgetInbox" v-if="isAWebWidgetInbox"
v-model="channelWelcomeTagline" v-model="channelWelcomeTagline"
class="pb-4" class="mb-4"
:label=" :label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
" "
@@ -492,6 +496,8 @@ export default {
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
/> />
<label v-if="isAWebWidgetInbox" class="pb-4"> <label v-if="isAWebWidgetInbox" class="pb-4">
@@ -7,13 +7,16 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage'; import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage'; import { LocalStorage } from 'shared/helpers/localStorage';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
Widget, Widget,
InputRadioGroup, InputRadioGroup,
NextButton, NextButton,
Editor,
}, },
props: { props: {
inbox: { inbox: {
@@ -71,6 +74,7 @@ export default {
checked: false, checked: false,
}, },
], ],
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -310,7 +314,7 @@ export default {
) )
" "
/> />
<woot-input <Editor
v-model="welcomeTagline" v-model="welcomeTagline"
:label=" :label="
$t( $t(
@@ -322,6 +326,9 @@ export default {
'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER' 'INBOX_MGMT.WIDGET_BUILDER.WIDGET_OPTIONS.WELCOME_TAGLINE.PLACE_HOLDER'
) )
" "
:max-length="255"
:enabled-menu-options="welcomeTaglineEditorMenuOptions"
class="mb-4"
/> />
<label> <label>
{{ {{
@@ -1,21 +1,17 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue'; import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue'; import Microsoft from './emailChannels/Microsoft.vue';
import Google from './emailChannels/Google.vue'; import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue'; import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store'; import { useStoreGetters } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
const provider = ref(''); const provider = ref('');
const getters = useStoreGetters(); const getters = useStoreGetters();
const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const globalConfig = getters['globalConfig/get']; const globalConfig = getters['globalConfig/get'];
@@ -49,15 +45,6 @@ const emailProviderList = computed(() => {
}); });
}); });
const checkForOAuthError = () => {
const { error } = route.query;
if (error) {
useAlert(decodeURIComponent(error));
}
};
onMounted(checkForOAuthError);
function onClick(emailProvider) { function onClick(emailProvider) {
if (emailProvider.isEnabled) { if (emailProvider.isEnabled) {
provider.value = emailProvider.key; provider.value = emailProvider.key;
@@ -5,12 +5,15 @@ import router from '../../../../index';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import PageHeader from '../../SettingsSubPageHeader.vue'; import PageHeader from '../../SettingsSubPageHeader.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue'; import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
export default { export default {
components: { components: {
PageHeader, PageHeader,
GreetingsEditor, GreetingsEditor,
NextButton, NextButton,
Editor,
}, },
data() { data() {
return { return {
@@ -21,6 +24,7 @@ export default {
channelWelcomeTagline: '', channelWelcomeTagline: '',
greetingEnabled: false, greetingEnabled: false,
greetingMessage: '', greetingMessage: '',
welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS,
}; };
}, },
computed: { computed: {
@@ -134,22 +138,21 @@ export default {
/> />
</label> </label>
</div> </div>
<div class="w-full"> <Editor
<label> v-model="channelWelcomeTagline"
{{ :label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL') $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.LABEL')
}} "
<input :placeholder="
v-model="channelWelcomeTagline" $t(
type="text" 'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER'
:placeholder=" )
$t( "
'INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WELCOME_TAGLINE.PLACEHOLDER' :max-length="255"
) :enabled-menu-options="welcomeTaglineEditorMenuOptions"
" class="mb-4"
/> />
</label>
</div>
<label class="w-full"> <label class="w-full">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }} {{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_GREETING_TOGGLE.LABEL') }}
<select v-model="greetingEnabled"> <select v-model="greetingEnabled">
@@ -12,6 +12,7 @@ defineOptions({
provider="google" provider="google"
:title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')" :title="$t('INBOX_MGMT.ADD.GOOGLE.TITLE')"
:description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.GOOGLE.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.GOOGLE.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.GOOGLE.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.GOOGLE.ERROR_MESSAGE')"
/> />
@@ -12,6 +12,7 @@ defineOptions({
provider="microsoft" provider="microsoft"
:title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')" :title="$t('INBOX_MGMT.ADD.MICROSOFT.TITLE')"
:description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')" :description="$t('INBOX_MGMT.ADD.MICROSOFT.DESCRIPTION')"
:input-placeholder="$t('INBOX_MGMT.ADD.MICROSOFT.EMAIL_PLACEHOLDER')"
:submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')" :submit-button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')" :error-message="$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE')"
/> />
@@ -30,9 +30,14 @@ const props = defineProps({
type: String, type: String,
required: true, required: true,
}, },
inputPlaceholder: {
type: String,
required: true,
},
}); });
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const email = ref('');
const client = computed(() => { const client = computed(() => {
if (props.provider === 'microsoft') { if (props.provider === 'microsoft') {
@@ -45,7 +50,9 @@ const client = computed(() => {
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await client.value.generateAuthorization(); const response = await client.value.generateAuthorization({
email: email.value,
});
const { const {
data: { url }, data: { url },
} = response; } = response;
@@ -68,6 +75,11 @@ async function requestAuthorization() {
:header-content="description" :header-content="description"
/> />
<form class="mt-6" @submit.prevent="requestAuthorization"> <form class="mt-6" @submit.prevent="requestAuthorization">
<woot-input
v-model="email"
type="email"
:placeholder="inputPlaceholder"
/>
<NextButton <NextButton
:is-loading="isRequestingAuthorization" :is-loading="isRequestingAuthorization"
type="submit" type="submit"
@@ -1,19 +1,35 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref, computed } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue'; import InboxReconnectionRequired from '../../components/InboxReconnectionRequired.vue';
import googleClient from 'dashboard/api/channel/googleClient'; import googleClient from 'dashboard/api/channel/googleClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
const inboxEmail = computed(() => {
if (props.inbox.imap_login && props.inbox.imap_enabled) {
return props.inbox.imap_login;
}
return props.inbox.email;
});
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await googleClient.generateAuthorization(); const response = await googleClient.generateAuthorization({
email: inboxEmail.value,
});
const { const {
data: { url }, data: { url },
@@ -6,6 +6,13 @@ import microsoftClient from 'dashboard/api/channel/microsoftClient';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n(); const { t } = useI18n();
const isRequestingAuthorization = ref(false); const isRequestingAuthorization = ref(false);
@@ -13,7 +20,9 @@ const isRequestingAuthorization = ref(false);
async function requestAuthorization() { async function requestAuthorization() {
try { try {
isRequestingAuthorization.value = true; isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization(); const response = await microsoftClient.generateAuthorization({
email: props.inbox.email,
});
const { const {
data: { url }, data: { url },
@@ -1,7 +1,5 @@
<template> <template>
<div <div class="reports--wrapper overflow-auto bg-n-background w-full px-6">
class="reports--wrapper overflow-auto bg-n-background w-full px-8 xl:px-0"
>
<div class="max-w-[60rem] mx-auto pb-12"> <div class="max-w-[60rem] mx-auto pb-12">
<router-view /> <router-view />
</div> </div>
@@ -6,9 +6,9 @@ export default createStore({
API: CopilotMessagesAPI, API: CopilotMessagesAPI,
getters: { getters: {
getMessagesByThreadId: state => copilotThreadId => { getMessagesByThreadId: state => copilotThreadId => {
return state.records.filter( return state.records
record => record.copilot_thread?.id === Number(copilotThreadId) .filter(record => record.copilot_thread?.id === Number(copilotThreadId))
); .sort((a, b) => a.id - b.id);
}, },
}, },
actions: mutationTypes => ({ actions: mutationTypes => ({
@@ -63,8 +63,11 @@ export const actions = {
}); });
} }
}, },
update: async ({ commit }, updateObj) => { update: async ({ commit }, { options, ...updateObj }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); if (options?.silent !== true) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try { try {
const response = await AccountAPI.update('', updateObj); const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data); commit(types.default.EDIT_ACCOUNT, response.data);
@@ -75,6 +75,21 @@ export const actions = {
} }
}, },
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => { show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true }); commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try { try {
@@ -327,6 +327,16 @@ const actions = {
} }
}, },
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) { addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state; const { currentInbox, appliedFilters } = state;
const { const {
@@ -47,6 +47,7 @@
* 3. Nested properties in custom_attributes (conversation_type, etc.) * 3. Nested properties in custom_attributes (conversation_type, etc.)
*/ */
import jsonLogic from 'json-logic-js'; import jsonLogic from 'json-logic-js';
import { coerceToDate } from '@chatwoot/utils';
/** /**
* Gets a value from a conversation based on the attribute key * Gets a value from a conversation based on the attribute key
@@ -157,6 +158,20 @@ const contains = (filterValue, conversationValue) => {
return false; return false;
}; };
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
const filterDate = coerceToDate(filterValue);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/** /**
* Checks if a value matches a filter condition * Checks if a value matches a filter condition
* @param {*} conversationValue - The value to check * @param {*} conversationValue - The value to check
@@ -195,10 +210,10 @@ const matchesCondition = (conversationValue, filter) => {
return false; // We already handled null/undefined above return false; // We already handled null/undefined above
case 'is_greater_than': case 'is_greater_than':
return new Date(conversationValue) > new Date(filterValue); return compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than': case 'is_less_than':
return new Date(conversationValue) < new Date(filterValue); return compareDates(conversationValue, filterValue, (a, b) => a < b);
case 'days_before': { case 'days_before': {
const today = new Date(); const today = new Date();
@@ -347,6 +362,7 @@ export const matchesFilters = (conversation, filters) => {
conversation, conversation,
filters[0].attribute_key filters[0].attribute_key
); );
return matchesCondition(value, filters[0]); return matchesCondition(value, filters[0]);
} }
@@ -463,6 +463,241 @@ describe('filterHelpers', () => {
expect(matchesFilters(conversation, filters)).toBe(true); expect(matchesFilters(conversation, filters)).toBe(true);
}); });
// Test conversation with 10-digit timestamp (seconds) vs standard date filter
it('should match conversation with 10-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 in seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with 13-digit timestamp (milliseconds) vs standard date filter
it('should match conversation with 13-digit timestamp against date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 in milliseconds (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string timestamp vs standard date filter
it('should match conversation with string 10-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600' }; // March 20, 2022 as string (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with string 13-digit timestamp vs standard date filter
it('should match conversation with string 13-digit timestamp against date string filter', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string (13 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19', // Standard YYYY-MM-DD format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test conversation with mixed format vs standard date filter with time
it('should match conversation with numeric timestamp against ISO date string filter', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022 12:00:00 GMT (numeric)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T10:30:00Z', // Standard ISO format from filter
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with date string without time (should default to 00:00:00)
it('should match conversation with is_greater_than operator using date string without time', () => {
const conversation = { created_at: 1647820800000 }; // March 21, 2022 00:00:00 GMT
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-20', // March 20, 2022 (should become 00:00:00)
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with ISO date string
it('should match conversation with is_greater_than operator using ISO date string', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19T00:00:00.000Z', // March 19, 2022 ISO format
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with null/undefined values
it('should handle null filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: null,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle undefined filter values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: undefined,
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test parseDate with invalid date strings
it('should handle invalid date strings in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'invalid-date-string',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
it('should handle non-date string values in date comparison', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: 'not-a-date',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Test is_less_than with various date formats
it('should match conversation with is_less_than operator using numeric timestamp', () => {
const conversation = { created_at: 1647691200000 }; // March 19, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: 1647777600, // March 20, 2022 as 10-digit timestamp
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
it('should not match conversation with is_less_than operator when date is later', () => {
const conversation = { created_at: 1647864000000 }; // March 21, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_less_than',
values: '2022-03-20T12:00:00Z', // March 20, 2022 with time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
// Edge case: Test with conversation having string timestamp
it('should handle conversation with string timestamp value', () => {
const conversation = { created_at: '1647777600000' }; // March 20, 2022 as string
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Edge case: Test with conversation having 10-digit timestamp
it('should handle conversation with 10-digit timestamp value', () => {
const conversation = { created_at: 1647777600 }; // March 20, 2022 as seconds (10 digits)
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19',
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test date string with different time formats
it('should handle date string with space-separated time', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: '2022-03-19 10:30:00', // Date with space-separated time
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(true);
});
// Test parseDate with object input (should return null and fail comparison)
it('should handle non-string, non-number filter values', () => {
const conversation = { created_at: 1647777600000 }; // March 20, 2022
const filters = [
{
attribute_key: 'created_at',
filter_operator: 'is_greater_than',
values: { date: '2022-03-19' }, // Object instead of string/number
query_operator: 'and',
},
];
expect(matchesFilters(conversation, filters)).toBe(false);
});
describe('days_before operator', () => { describe('days_before operator', () => {
beforeEach(() => { beforeEach(() => {
// Set the date to March 25, 2022 // Set the date to March 25, 2022
@@ -204,6 +204,12 @@ export const mutations = {
_state.allConversations.push(conversation); _state.allConversations.push(conversation);
}, },
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) { [types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state; const { allConversations } = _state;
const index = allConversations.findIndex(c => c.id === conversation.id); const index = allConversations.findIndex(c => c.id === conversation.id);
@@ -70,6 +70,30 @@ describe('#actions', () => {
}); });
}); });
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => { describe('#update', () => {
it('sends correct mutations if API is success', async () => { it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } }); axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
@@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]); expect(commit.mock.calls).toEqual([]);
}); });
describe('#deleteConversation', () => {
it('send correct actions if API is success', async () => {
axios.delete.mockResolvedValue({
data: { id: 1 },
});
await actions.deleteConversation({ commit, dispatch }, 1);
expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
expect(dispatch.mock.calls).toEqual([
['conversationStats/get', {}, { root: true }],
]);
});
it('send no actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.deleteConversation({ commit, dispatch }, 1)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
});
describe('#updateCustomAttributes', () => { describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => { it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({ axios.post.mockResolvedValue({
@@ -884,6 +884,17 @@ describe('#mutations', () => {
}); });
}); });
describe('#DELETE_CONVERSATION', () => {
it('should delete a conversation', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.DELETE_CONVERSATION](state, 1);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => { describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => { it('should set listLoadingStatus to true', () => {
const state = { const state = {
@@ -56,6 +56,7 @@ export default {
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS', SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS', ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS', DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION: 'DELETE_CONVERSATION',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY', SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
@@ -1,6 +1,7 @@
<script setup> <script setup>
import HeaderActions from './HeaderActions.vue'; import HeaderActions from './HeaderActions.vue';
import { computed } from 'vue'; import { computed } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
const props = defineProps({ const props = defineProps({
avatarUrl: { avatarUrl: {
@@ -21,6 +22,8 @@ const props = defineProps({
}, },
}); });
const { formatMessage } = useMessageFormatter();
const containerClasses = computed(() => [ const containerClasses = computed(() => [
props.avatarUrl ? 'justify-between' : 'justify-end', props.avatarUrl ? 'justify-between' : 'justify-end',
]); ]);
@@ -47,8 +50,8 @@ const containerClasses = computed(() => [
class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12" class="mt-4 text-2xl mb-1.5 font-medium text-n-slate-12"
/> />
<p <p
v-dompurify-html="introBody" v-dompurify-html="formatMessage(introBody)"
class="text-lg leading-normal text-n-slate-11" class="text-lg leading-normal text-n-slate-11 [&_a]:underline"
/> />
</header> </header>
</template> </template>
+1
View File
@@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
# send message from bot that conversation has been resolved # send message from bot that conversation has been resolved
# do this is account.auto_resolve_message is set # do this is account.auto_resolve_message is set
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present? ::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
conversation.toggle_status conversation.toggle_status
end end
end end
@@ -4,7 +4,8 @@ class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
@agent = agent @agent = agent
@conversation = conversation @conversation = conversation
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{@conversation.inbox&.name}." inbox_name = @conversation.inbox&.sanitized_name
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id) @action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return send_mail_with_liquid(to: @agent.email, subject: subject) and return
end end
+1 -1
View File
@@ -104,7 +104,7 @@ class ConversationReplyMailer < ApplicationMailer
end end
def business_name def business_name
@inbox.business_name || @inbox.name @inbox.business_name || @inbox.sanitized_name
end end
def from_email def from_email
+4 -1
View File
@@ -35,7 +35,9 @@ class Account < ApplicationRecord
{ {
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 }, 'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] }, 'auto_resolve_message': { 'type': %w[string null] },
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] } 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
}, },
'required': [], 'required': [],
'additionalProperties': true 'additionalProperties': true
@@ -52,6 +54,7 @@ class Account < ApplicationRecord
attribute_resolver: ->(record) { record.settings } attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
has_many :account_users, dependent: :destroy_async has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async
+13 -7
View File
@@ -76,18 +76,24 @@ class Article < ApplicationRecord
scope :order_by_views, -> { reorder(views: :desc) } scope :order_by_views, -> { reorder(views: :desc) }
# TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS # TODO: if text search slows down https://www.postgresql.org/docs/current/textsearch-features.html#TEXTSEARCH-UPDATE-TRIGGERS
# - the A, B and C are for weightage. See: https://github.com/Casecommons/pg_search#weighting
# - the normalization is for ensuring the long articles that mention the search term too many times are not ranked higher.
# it divides rank by log(document_length) to prevent longer articles from ranking higher just due to sizeSee: https://github.com/Casecommons/pg_search#normalization
# - the ranking is to ensure that articles with higher weightage are ranked higher
pg_search_scope( pg_search_scope(
:text_search, :text_search,
against: %i[ against: {
title title: 'A',
description description: 'B',
content content: 'C'
], },
using: { using: {
tsearch: { tsearch: {
prefix: true prefix: true,
normalization: 2
} }
} },
ranked_by: ':tsearch'
) )
def self.search(params) def self.search(params)
+27 -4
View File
@@ -44,11 +44,8 @@ class Attachment < ApplicationRecord
def push_event_data def push_event_data
return unless file_type return unless file_type
return base_data.merge(location_metadata) if file_type.to_sym == :location
return base_data.merge(fallback_data) if file_type.to_sym == :fallback
return base_data.merge(contact_metadata) if file_type.to_sym == :contact
base_data.merge(file_metadata) base_data.merge(metadata_for_file_type)
end end
# NOTE: the URl returned does a 301 redirect to the actual file # NOTE: the URl returned does a 301 redirect to the actual file
@@ -76,6 +73,30 @@ class Attachment < ApplicationRecord
private private
def metadata_for_file_type
case file_type.to_sym
when :location
location_metadata
when :fallback
fallback_data
when :contact
contact_metadata
when :audio
audio_metadata
else
file_metadata
end
end
def audio_metadata
audio_file_data = base_data.merge(file_metadata)
audio_file_data.merge(
{
transcribed_text: meta&.[]('transcribed_text') || ''
}
)
end
def file_metadata def file_metadata
metadata = { metadata = {
extension: extension, extension: extension,
@@ -149,3 +170,5 @@ class Attachment < ApplicationRecord
file_content_type.start_with?('image/', 'video/', 'audio/') file_content_type.start_with?('image/', 'video/', 'audio/')
end end
end end
Attachment.include_mod_with('Concerns::Attachment')
+1
View File
@@ -305,5 +305,6 @@ class Conversation < ApplicationRecord
end end
end end
Conversation.include_mod_with('Audit::Conversation')
Conversation.include_mod_with('Concerns::Conversation') Conversation.include_mod_with('Concerns::Conversation')
Conversation.prepend_mod_with('Conversation') Conversation.prepend_mod_with('Conversation')
+26 -2
View File
@@ -47,8 +47,6 @@ class Inbox < ApplicationRecord
# Not allowing characters: # Not allowing characters:
validates :name, presence: true validates :name, presence: true
validates :name, if: :check_channel_type?, format: { with: %r{^^\b[^/\\<>@]*\b$}, multiline: true,
message: I18n.t('errors.inboxes.validations.name') }
validates :account_id, presence: true validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers } validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH } validates :out_of_office_message, length: { maximum: Limits::OUT_OF_OFFICE_MESSAGE_MAX_LENGTH }
@@ -99,6 +97,16 @@ class Inbox < ApplicationRecord
update_account_cache update_account_cache
end end
# Sanitizes inbox name for balanced email provider compatibility
# ALLOWS: /'._- and Unicode letters/numbers/emojis
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
def sanitized_name
return default_name_for_blank_name if name.blank?
sanitized = apply_sanitization_rules(name)
sanitized.blank? && email? ? display_name_from_email : sanitized
end
def sms? def sms?
channel_type == 'Channel::Sms' channel_type == 'Channel::Sms'
end end
@@ -178,6 +186,22 @@ class Inbox < ApplicationRecord
private private
def default_name_for_blank_name
email? ? display_name_from_email : ''
end
def apply_sanitization_rules(name)
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~]/, '') # Remove forbidden chars
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
.gsub(/\s+/, ' ') # Normalize spaces
.strip
end
def display_name_from_email
channel.email.split('@').first.parameterize.titleize
end
def dispatch_create_event def dispatch_create_event
return if ENV['ENABLE_INBOX_EVENTS'].blank? return if ENV['ENABLE_INBOX_EVENTS'].blank?
+6 -2
View File
@@ -224,6 +224,11 @@ class Message < ApplicationRecord
save! save!
end end
def send_update_event
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by,
previous_changes: previous_changes)
end
private private
def prevent_message_flooding def prevent_message_flooding
@@ -313,8 +318,7 @@ class Message < ApplicationRecord
# we want to skip the update event if the message is not updated # we want to skip the update event if the message is not updated
return if previous_changes.blank? return if previous_changes.blank?
Rails.configuration.dispatcher.dispatch(MESSAGE_UPDATED, Time.zone.now, message: self, performed_by: Current.executed_by, send_update_event
previous_changes: previous_changes)
end end
def send_reply def send_reply
+1 -1
View File
@@ -12,7 +12,7 @@ class ContactPolicy < ApplicationPolicy
end end
def export? def export?
@account_user.administrator? @account_user.administrator? || @account_user.agent?
end end
def search? def search?

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