Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b31b8b93c | ||
|
|
4502ed6213 | ||
|
|
f0e376c0d9 | ||
|
|
0be8af9c3d | ||
|
|
f6c4224400 | ||
|
|
8f47496373 | ||
|
|
0990e33860 | ||
|
|
c93c6aed5f | ||
|
|
0381aa188a | ||
|
|
90e212a81a | ||
|
|
76e1643ccf | ||
|
|
0b56add844 | ||
|
|
e9b99a5811 | ||
|
|
a910758fd8 | ||
|
|
aa68525b6b | ||
|
|
ba1f363a28 | ||
|
|
b8e64722a7 | ||
|
|
153d0371ff | ||
|
|
c66f8600ec | ||
|
|
38b5c61d51 | ||
|
|
81f5c9aa12 | ||
|
|
532c5c3324 | ||
|
|
2dca1eee89 | ||
|
|
851f622fe1 | ||
|
|
1503ddeda4 | ||
|
|
c645ba3922 | ||
|
|
5d64079a5e | ||
|
|
82e296b152 | ||
|
|
016a8541c1 | ||
|
|
d289daf6ca | ||
|
|
c8cc157683 | ||
|
|
3fd506c27f | ||
|
|
d8a1fdbce5 | ||
|
|
6840108f30 | ||
|
|
d7288835fa | ||
|
|
31e1d59d58 | ||
|
|
4f55a66fba | ||
|
|
3e73c1b4bc | ||
|
|
25f947223d | ||
|
|
9b43a0f72b | ||
|
|
10363e77ad | ||
|
|
27bce50210 | ||
|
|
8bc00f707b | ||
|
|
273c277d47 | ||
|
|
4c0d096e4d | ||
|
|
2f40f95f77 | ||
|
|
ee293c3598 | ||
|
|
513d954027 | ||
|
|
312c8a1a74 | ||
|
|
79f7afaeec | ||
|
|
4e7ca49297 | ||
|
|
db51d2d011 | ||
|
|
6f86944758 | ||
|
|
d74798fcd5 | ||
|
|
394419ce95 | ||
|
|
4fe9b88642 | ||
|
|
e9a132a923 | ||
|
|
b70d2c0ebe | ||
|
|
623734a631 | ||
|
|
b81f1bc971 | ||
|
|
ff0ad53f49 | ||
|
|
0f8037b6fc | ||
|
|
38938fe776 | ||
|
|
02ca4136ad | ||
|
|
da4049d18a | ||
|
|
088608abcf | ||
|
|
afb3c0109b | ||
|
|
c54973eeb2 | ||
|
|
cb2785096e | ||
|
|
27f35a7dd8 | ||
|
|
59790e38da | ||
|
|
461f1b3bf0 | ||
|
|
4056a144e4 | ||
|
|
31681610f2 | ||
|
|
a8b33023f8 | ||
|
|
8c5204d0c9 | ||
|
|
aa5242fdb0 | ||
|
|
b591893486 | ||
|
|
a118bef98e | ||
|
|
daa54f6866 | ||
|
|
c38e7af0e7 | ||
|
|
d6f862a85c | ||
|
|
13f742fd68 | ||
|
|
b74e78f698 | ||
|
|
5d8646a3f3 | ||
|
|
453eba9a81 | ||
|
|
6beb355f29 | ||
|
|
980eca46c6 | ||
|
|
0868a9af70 | ||
|
|
da6fbe8579 | ||
|
|
2c814fff99 | ||
|
|
c1c44512d4 | ||
|
|
1138195846 | ||
|
|
84d31b91f4 | ||
|
|
2df2459674 | ||
|
|
81e18aaafa | ||
|
|
f07a5215cf | ||
|
|
1899a31416 | ||
|
|
60f5145365 | ||
|
|
e58736e746 | ||
|
|
a9fd459cf4 | ||
|
|
7235e44a9d | ||
|
|
ddd1fb5396 | ||
|
|
a97cf6e4ad | ||
|
|
4ecc92ad6d | ||
|
|
ff70fbe2e4 | ||
|
|
4f27f1605a | ||
|
|
e00b54ce07 | ||
|
|
df20a59a6c | ||
|
|
bb6f7b9fcd | ||
|
|
e28c6312e4 | ||
|
|
fc119d14d9 | ||
|
|
9243b6367c | ||
|
|
5af8881a5a | ||
|
|
70029177da | ||
|
|
1abb04f592 | ||
|
|
355b587c2c | ||
|
|
b22a903446 | ||
|
|
2d5993dfe3 | ||
|
|
db50f42054 | ||
|
|
f8afc1509f |
@@ -94,3 +94,6 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
|
|
||||||
|
# react component
|
||||||
|
dist
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
|
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
|
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||||
vite: BUILD_MODE=dev bin/vite build --watch
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController
|
|||||||
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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ class ApiClient {
|
|||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
get accountIdFromRoute() {
|
get accountIdFromRoute() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ACCOUNT_ID__) {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__WOOT_ACCOUNT_ID__;
|
||||||
|
}
|
||||||
|
|
||||||
const isInsideAccountScopedURLs =
|
const isInsideAccountScopedURLs =
|
||||||
window.location.pathname.includes('/app/accounts');
|
window.location.pathname.includes('/app/accounts');
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,14 @@ import {
|
|||||||
} from '../store/utils/api';
|
} from '../store/utils/api';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
validityCheck() {
|
async validityCheck() {
|
||||||
|
if (this.hasAuthToken()) {
|
||||||
|
const urlData = endPoints('profileUpdate');
|
||||||
|
const response = await axios.get(urlData.url);
|
||||||
|
// to match the response signature of the validityCheck endpoint
|
||||||
|
return Promise.resolve({ data: { payload: response } });
|
||||||
|
}
|
||||||
|
|
||||||
const urlData = endPoints('validityCheck');
|
const urlData = endPoints('validityCheck');
|
||||||
return axios.get(urlData.url);
|
return axios.get(urlData.url);
|
||||||
},
|
},
|
||||||
@@ -31,6 +38,10 @@ export default {
|
|||||||
hasAuthCookie() {
|
hasAuthCookie() {
|
||||||
return !!Cookies.get('cw_d_session_info');
|
return !!Cookies.get('cw_d_session_info');
|
||||||
},
|
},
|
||||||
|
hasAuthToken() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return !!window.__WOOT_ACCESS_TOKEN__;
|
||||||
|
},
|
||||||
getAuthData() {
|
getAuthData() {
|
||||||
if (this.hasAuthCookie()) {
|
if (this.hasAuthCookie()) {
|
||||||
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
const savedAuthInfo = Cookies.get('cw_d_session_info');
|
||||||
|
|||||||
@@ -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)}`;
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ class ConversationApi extends ApiClient {
|
|||||||
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();
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
/* global axios */
|
/* global axios */
|
||||||
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
import CacheEnabledApiClient from './CacheEnabledApiClient';
|
||||||
|
// import ApiClient from './ApiClient';
|
||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
class Inboxes extends CacheEnabledApiClient {
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const BaseClass = window.__WOOT_ISOLATED_SHELL__
|
||||||
|
? ApiClient
|
||||||
|
: CacheEnabledApiClient;
|
||||||
|
|
||||||
|
class Inboxes extends BaseClass {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('inboxes', { accountScoped: true });
|
super('inboxes', { accountScoped: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
+18
-39
@@ -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"
|
||||||
|
|||||||
+2
@@ -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"
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ const getCurrentRoute = () => {
|
|||||||
const path = route.path;
|
const path = route.path;
|
||||||
if (path.includes('/conversations')) return 'conversations';
|
if (path.includes('/conversations')) return 'conversations';
|
||||||
if (path.includes('/dashboard')) return 'dashboard';
|
if (path.includes('/dashboard')) return 'dashboard';
|
||||||
if (path.includes('/contacts')) return 'contacts';
|
|
||||||
if (path.includes('/articles')) return 'articles';
|
|
||||||
return 'dashboard';
|
return 'dashboard';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const showCopilotLauncher = computed(() => {
|
|||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
updateUISettings({
|
updateUISettings({
|
||||||
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
|
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
|
||||||
|
is_contact_sidebar_open: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -315,6 +315,9 @@ const componentToRender = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const shouldShowContextMenu = computed(() => {
|
const shouldShowContextMenu = computed(() => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ISOLATED_SHELL__) return false;
|
||||||
|
|
||||||
return !props.contentAttributes?.isUnsupported;
|
return !props.contentAttributes?.isUnsupported;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -441,7 +444,7 @@ const avatarTooltip = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const setupHighlightTimer = () => {
|
const setupHighlightTimer = () => {
|
||||||
if (Number(route.query.messageId) !== Number(props.id)) {
|
if (Number(route?.query?.messageId) !== Number(props.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="w-8 h-4"
|
||||||
|
viewBox="0 0 120 30"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<circle cx="15" cy="15" r="12">
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
from="15"
|
||||||
|
to="5"
|
||||||
|
begin="0s"
|
||||||
|
dur="0.9s"
|
||||||
|
values="15;5;15;15;15;15"
|
||||||
|
calcMode="linear"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="55" cy="15" r="12">
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
from="15"
|
||||||
|
to="5"
|
||||||
|
begin="0.1s"
|
||||||
|
dur="0.9s"
|
||||||
|
values="15;5;15;15;15;15"
|
||||||
|
calcMode="linear"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
<circle cx="95" cy="15" r="12">
|
||||||
|
<animate
|
||||||
|
attributeName="cy"
|
||||||
|
from="15"
|
||||||
|
to="5"
|
||||||
|
begin="0.2s"
|
||||||
|
dur="0.9s"
|
||||||
|
values="15;5;15;15;15;15"
|
||||||
|
calcMode="linear"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
/>
|
||||||
|
</circle>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
|||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
import Button from 'next/button/Button.vue';
|
import Button from 'next/button/Button.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { useGallery } from '../useGallery';
|
||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { downloadFile } from '@chatwoot/utils';
|
import { downloadFile } from '@chatwoot/utils';
|
||||||
@@ -21,8 +22,8 @@ const attachment = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const showGallery = ref(false);
|
|
||||||
const isDownloading = ref(false);
|
const isDownloading = ref(false);
|
||||||
|
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
hasError.value = true;
|
hasError.value = true;
|
||||||
@@ -46,7 +47,7 @@ const downloadAttachment = async () => {
|
|||||||
<BaseBubble
|
<BaseBubble
|
||||||
class="overflow-hidden p-3"
|
class="overflow-hidden p-3"
|
||||||
data-bubble-name="image"
|
data-bubble-name="image"
|
||||||
@click="showGallery = true"
|
@click="toggleGallery(true)"
|
||||||
>
|
>
|
||||||
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
<div v-if="hasError" class="flex items-center gap-1 text-center rounded-lg">
|
||||||
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
<Icon icon="i-lucide-circle-off" class="text-n-slate-11" />
|
||||||
@@ -82,7 +83,7 @@ const downloadAttachment = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="showGallery"
|
v-if="showGallery && isGalleryAllowed"
|
||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import BaseBubble from './Base.vue';
|
import BaseBubble from './Base.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { useGallery } from '../useGallery';
|
||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||||
@@ -9,7 +10,7 @@ import { ATTACHMENT_TYPES } from '../constants';
|
|||||||
|
|
||||||
const emit = defineEmits(['error']);
|
const emit = defineEmits(['error']);
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const showGallery = ref(false);
|
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||||
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
const { filteredCurrentChatAttachments, attachments } = useMessageContext();
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
@@ -30,7 +31,7 @@ const isReel = computed(() => {
|
|||||||
<BaseBubble
|
<BaseBubble
|
||||||
class="overflow-hidden p-3"
|
class="overflow-hidden p-3"
|
||||||
data-bubble-name="video"
|
data-bubble-name="video"
|
||||||
@click="showGallery = true"
|
@click="toggleGallery(true)"
|
||||||
>
|
>
|
||||||
<div class="relative group rounded-lg overflow-hidden">
|
<div class="relative group rounded-lg overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -52,7 +53,7 @@ const isReel = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="showGallery"
|
v-if="showGallery && isGalleryAllowed"
|
||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { useGallery } from '../useGallery';
|
||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const hasError = ref(false);
|
const hasError = ref(false);
|
||||||
const showGallery = ref(false);
|
const { showGallery, toggleGallery, isGalleryAllowed } = useGallery();
|
||||||
|
|
||||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ const handleError = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
|
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer"
|
||||||
@click="showGallery = true"
|
@click="toggleGallery(true)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="hasError"
|
v-if="hasError"
|
||||||
@@ -42,7 +43,7 @@ const handleError = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="showGallery"
|
v-if="showGallery && isGalleryAllowed"
|
||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import { useGallery } from '../useGallery';
|
||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
|
||||||
@@ -12,7 +12,7 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const showGallery = ref(false);
|
const { showGallery, isGalleryAllowed, toggleGallery } = useGallery();
|
||||||
|
|
||||||
const { filteredCurrentChatAttachments } = useMessageContext();
|
const { filteredCurrentChatAttachments } = useMessageContext();
|
||||||
</script>
|
</script>
|
||||||
@@ -20,7 +20,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
|
class="size-[72px] overflow-hidden contain-content rounded-xl cursor-pointer relative group"
|
||||||
@click="showGallery = true"
|
@click="toggleGallery(true)"
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
:src="attachment.dataUrl"
|
:src="attachment.dataUrl"
|
||||||
@@ -42,7 +42,7 @@ const { filteredCurrentChatAttachments } = useMessageContext();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="showGallery"
|
v-if="showGallery && isGalleryAllowed"
|
||||||
v-model:show="showGallery"
|
v-model:show="showGallery"
|
||||||
:attachment="useSnakeCase(attachment)"
|
:attachment="useSnakeCase(attachment)"
|
||||||
:all-attachments="filteredCurrentChatAttachments"
|
:all-attachments="filteredCurrentChatAttachments"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
|
||||||
|
export function useGallery() {
|
||||||
|
const [showGallery] = useToggle(false);
|
||||||
|
|
||||||
|
const isGalleryAllowed = computed(() => {
|
||||||
|
return true;
|
||||||
|
// return !window.__WOOT_ISOLATED_SHELL__;
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleGallery = value => {
|
||||||
|
if (!isGalleryAllowed.value) return;
|
||||||
|
|
||||||
|
showGallery.value = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
showGallery,
|
||||||
|
isGalleryAllowed,
|
||||||
|
toggleGallery,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ const shouldShowCannedResponses = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const editorMenuOptions = computed(() => {
|
const editorMenuOptions = computed(() => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ISOLATED_SHELL__) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
return props.enabledMenuOptions.length
|
return props.enabledMenuOptions.length
|
||||||
? props.enabledMenuOptions
|
? props.enabledMenuOptions
|
||||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||||
@@ -422,10 +426,14 @@ function updateImgToolbarOnDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isEnterToSendEnabled() {
|
function isEnterToSendEnabled() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ISOLATED_SHELL__) return false;
|
||||||
return isEditorHotKeyEnabled('enter');
|
return isEditorHotKeyEnabled('enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCmdPlusEnterToSendEnabled() {
|
function isCmdPlusEnterToSendEnabled() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ISOLATED_SHELL__) return true;
|
||||||
return isEditorHotKeyEnabled('cmd_enter');
|
return isEditorHotKeyEnabled('cmd_enter');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,7 +661,7 @@ watch(sendWithSignature, newValue => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
// [VITE] state assignment was done in created before
|
// [VITE] state assignment was done in created before
|
||||||
state = createState(
|
state = createState(
|
||||||
props.modelValue,
|
props.modelValue,
|
||||||
@@ -734,6 +742,10 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor);
|
|||||||
.ProseMirror-menubar-wrapper {
|
.ProseMirror-menubar-wrapper {
|
||||||
@apply flex flex-col;
|
@apply flex flex-col;
|
||||||
|
|
||||||
|
.ProseMirror-menubar:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.ProseMirror-menubar {
|
.ProseMirror-menubar {
|
||||||
min-height: var(--space-two) !important;
|
min-height: var(--space-two) !important;
|
||||||
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
@apply -ml-2.5 pb-0 bg-transparent text-n-slate-11;
|
||||||
|
|||||||
@@ -55,10 +55,16 @@ const translateValue = computed(() => {
|
|||||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
|
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
|
||||||
@click="$emit('toggleMode')"
|
@click="$emit('toggleMode')"
|
||||||
>
|
>
|
||||||
<div ref="wootEditorReplyMode" class="flex items-center gap-1 px-2 z-20">
|
<div
|
||||||
|
ref="wootEditorReplyMode"
|
||||||
|
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
|
||||||
|
>
|
||||||
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
|
{{ $t('CONVERSATION.REPLYBOX.REPLY') }}
|
||||||
</div>
|
</div>
|
||||||
<div ref="wootEditorPrivateMode" class="flex items-center gap-1 px-2 z-20">
|
<div
|
||||||
|
ref="wootEditorPrivateMode"
|
||||||
|
class="flex items-center gap-1 px-2 z-20 text-n-slate-11"
|
||||||
|
>
|
||||||
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
{{ $t('CONVERSATION.REPLYBOX.PRIVATE_NOTE') }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
allowSignature: { type: Boolean, default: false },
|
||||||
|
allowEmoji: { type: Boolean, default: false },
|
||||||
|
allowAiAssist: { type: Boolean, default: false },
|
||||||
|
allowVideoCall: { type: Boolean, default: false },
|
||||||
|
allowFileUpload: { type: Boolean, default: false },
|
||||||
|
allowAudioRecorder: { type: Boolean, default: false },
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
'replaceText',
|
||||||
@@ -264,6 +270,7 @@ export default {
|
|||||||
<div class="flex justify-between p-3" :class="wrapClass">
|
<div class="flex justify-between p-3" :class="wrapClass">
|
||||||
<div class="left-wrap">
|
<div class="left-wrap">
|
||||||
<NextButton
|
<NextButton
|
||||||
|
v-if="allowEmoji"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||||
icon="i-ph-smiley-sticker"
|
icon="i-ph-smiley-sticker"
|
||||||
slate
|
slate
|
||||||
@@ -272,6 +279,7 @@ export default {
|
|||||||
@click="toggleEmojiPicker"
|
@click="toggleEmojiPicker"
|
||||||
/>
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
|
v-if="allowFileUpload"
|
||||||
ref="uploadRef"
|
ref="uploadRef"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||||
input-id="conversationAttachment"
|
input-id="conversationAttachment"
|
||||||
@@ -295,36 +303,40 @@ export default {
|
|||||||
sm
|
sm
|
||||||
/>
|
/>
|
||||||
</FileUpload>
|
</FileUpload>
|
||||||
|
<template v-if="allowAudioRecorder">
|
||||||
|
<NextButton
|
||||||
|
v-if="showAudioRecorderButton"
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
||||||
|
:icon="
|
||||||
|
!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'
|
||||||
|
"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
sm
|
||||||
|
@click="toggleAudioRecorder"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showEditorToggle"
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||||
|
icon="i-ph-quotes"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
sm
|
||||||
|
@click="$emit('toggleEditor')"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
v-if="showAudioPlayStopButton"
|
||||||
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||||
|
:icon="audioRecorderPlayStopIcon"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
sm
|
||||||
|
:label="recordingAudioDurationText"
|
||||||
|
@click="toggleAudioRecorderPlayPause"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<NextButton
|
<NextButton
|
||||||
v-if="showAudioRecorderButton"
|
v-if="allowSignature && showMessageSignatureButton"
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
|
|
||||||
:icon="!isRecordingAudio ? 'i-ph-microphone' : 'i-ph-microphone-slash'"
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
@click="toggleAudioRecorder"
|
|
||||||
/>
|
|
||||||
<NextButton
|
|
||||||
v-if="showEditorToggle"
|
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
|
||||||
icon="i-ph-quotes"
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
@click="$emit('toggleEditor')"
|
|
||||||
/>
|
|
||||||
<NextButton
|
|
||||||
v-if="showAudioPlayStopButton"
|
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
|
||||||
:icon="audioRecorderPlayStopIcon"
|
|
||||||
slate
|
|
||||||
faded
|
|
||||||
sm
|
|
||||||
:label="recordingAudioDurationText"
|
|
||||||
@click="toggleAudioRecorderPlayPause"
|
|
||||||
/>
|
|
||||||
<NextButton
|
|
||||||
v-if="showMessageSignatureButton"
|
|
||||||
v-tooltip.top-end="signatureToggleTooltip"
|
v-tooltip.top-end="signatureToggleTooltip"
|
||||||
icon="i-ph-signature"
|
icon="i-ph-signature"
|
||||||
slate
|
slate
|
||||||
@@ -342,11 +354,15 @@ export default {
|
|||||||
@click="$emit('selectWhatsappTemplate')"
|
@click="$emit('selectWhatsappTemplate')"
|
||||||
/>
|
/>
|
||||||
<VideoCallButton
|
<VideoCallButton
|
||||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
v-if="
|
||||||
|
allowVideoCall &&
|
||||||
|
(isAWebWidgetInbox || isAPIInbox) &&
|
||||||
|
!isOnPrivateNote
|
||||||
|
"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
/>
|
/>
|
||||||
<AIAssistanceButton
|
<AIAssistanceButton
|
||||||
v-if="!isFetchingAppIntegrations"
|
v-if="allowAiAssist && !isFetchingAppIntegrations"
|
||||||
:conversation-id="conversationId"
|
:conversation-id="conversationId"
|
||||||
:is-private-note="isOnPrivateNote"
|
:is-private-note="isOnPrivateNote"
|
||||||
:message="message"
|
:message="message"
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: REPLY_EDITOR_MODES.REPLY,
|
default: REPLY_EDITOR_MODES.REPLY,
|
||||||
},
|
},
|
||||||
|
disablePopout: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
isMessageLengthReachingThreshold: {
|
isMessageLengthReachingThreshold: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: () => false,
|
default: () => false,
|
||||||
@@ -85,7 +89,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between h-[3.25rem] gap-2 ltr:pl-3 rtl:pr-3">
|
<div class="flex justify-between h-[3.25rem] gap-2 ms-3">
|
||||||
<EditorModeToggle
|
<EditorModeToggle
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
@@ -99,6 +103,7 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NextButton
|
<NextButton
|
||||||
|
v-if="!disablePopout"
|
||||||
ghost
|
ghost
|
||||||
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
|
class="ltr:rounded-bl-md rtl:rounded-br-md ltr:rounded-br-none rtl:rounded-bl-none ltr:rounded-tl-none rtl:rounded-tr-none text-n-slate-11 ltr:rounded-tr-[11px] rtl:rounded-tl-[11px]"
|
||||||
icon="i-lucide-maximize-2"
|
icon="i-lucide-maximize-2"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1240,6 +1240,12 @@ export default {
|
|||||||
:message="message"
|
:message="message"
|
||||||
:portal-slug="connectedPortalSlug"
|
:portal-slug="connectedPortalSlug"
|
||||||
:new-conversation-modal-active="newConversationModalActive"
|
:new-conversation-modal-active="newConversationModalActive"
|
||||||
|
allow-signature
|
||||||
|
allow-emoji
|
||||||
|
allow-ai-assist
|
||||||
|
allow-video-call
|
||||||
|
allow-audio-recorder
|
||||||
|
allow-file-upload
|
||||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||||
@toggle-editor="toggleRichContentEditor"
|
@toggle-editor="toggleRichContentEditor"
|
||||||
@replace-text="replaceText"
|
@replace-text="replaceText"
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12"
|
class="flex-1 mx-2 px-2 truncate text-sm font-medium text-center text-n-slate-12 hidden sm:block"
|
||||||
>
|
>
|
||||||
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
<span v-dompurify-html="fileNameFromDataUrl" class="truncate" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { computed, unref } from 'vue';
|
|||||||
import { getCurrentInstance } from 'vue';
|
import { getCurrentInstance } from 'vue';
|
||||||
|
|
||||||
export const useStore = () => {
|
export const useStore = () => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__CHATWOOT_STORE__) {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__CHATWOOT_STORE__;
|
||||||
|
}
|
||||||
|
|
||||||
const vm = getCurrentInstance();
|
const vm = getCurrentInstance();
|
||||||
if (!vm) throw new Error('must be called in setup');
|
if (!vm) throw new Error('must be called in setup');
|
||||||
return vm.proxy.$store;
|
return vm.proxy.$store;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ const { isImpersonating } = useImpersonation();
|
|||||||
class ActionCableConnector extends BaseActionCableConnector {
|
class ActionCableConnector extends BaseActionCableConnector {
|
||||||
constructor(app, pubsubToken) {
|
constructor(app, pubsubToken) {
|
||||||
const { websocketURL = '' } = window.chatwootConfig || {};
|
const { websocketURL = '' } = window.chatwootConfig || {};
|
||||||
super(app, pubsubToken, websocketURL);
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const wsURL = websocketURL || window.__WEBSOCKET_URL__ || '';
|
||||||
|
super(app, pubsubToken, wsURL);
|
||||||
this.CancelTyping = [];
|
this.CancelTyping = [];
|
||||||
this.events = {
|
this.events = {
|
||||||
'message.created': this.onMessageCreated,
|
'message.created': this.onMessageCreated,
|
||||||
@@ -48,7 +51,9 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
};
|
};
|
||||||
|
|
||||||
isAValidEvent = data => {
|
isAValidEvent = data => {
|
||||||
return this.app.$store.getters.getCurrentAccountId === data.account_id;
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const currentAccountId = this.app.$store.getters.getCurrentAccountId;
|
||||||
|
return currentAccountId === data.account_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMessageUpdated = data => {
|
onMessageUpdated = data => {
|
||||||
@@ -98,7 +103,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
|||||||
conversation: { last_activity_at: lastActivityAt },
|
conversation: { last_activity_at: lastActivityAt },
|
||||||
conversation_id: conversationId,
|
conversation_id: conversationId,
|
||||||
} = data;
|
} = data;
|
||||||
DashboardAudioNotificationHelper.onNewMessage(data);
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (!window.__WOOT_ISOLATED_SHELL__) {
|
||||||
|
DashboardAudioNotificationHelper.onNewMessage(data);
|
||||||
|
}
|
||||||
this.app.$store.dispatch('addMessage', data);
|
this.app.$store.dispatch('addMessage', data);
|
||||||
this.app.$store.dispatch('updateConversationLastActivity', {
|
this.app.$store.dispatch('updateConversationLastActivity', {
|
||||||
lastActivityAt,
|
lastActivityAt,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -205,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"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
+51
@@ -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>
|
||||||
+91
-27
@@ -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>
|
||||||
|
|||||||
+13
-15
@@ -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>
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ export const getters = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getCurrentAccountId(_, __, rootState) {
|
getCurrentAccountId(_, __, rootState) {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
if (window.__WOOT_ACCOUNT_ID__) return window.__WOOT_ACCOUNT_ID__;
|
||||||
|
|
||||||
if (rootState.route.params && rootState.route.params.accountId) {
|
if (rootState.route.params && rootState.route.params.accountId) {
|
||||||
return Number(rootState.route.params.accountId);
|
return Number(rootState.route.params.accountId);
|
||||||
}
|
}
|
||||||
@@ -114,7 +117,7 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async setUser({ commit, dispatch }) {
|
async setUser({ commit, dispatch }) {
|
||||||
if (authAPI.hasAuthCookie()) {
|
if (authAPI.hasAuthCookie() || authAPI.hasAuthToken()) {
|
||||||
await dispatch('validityCheck');
|
await dispatch('validityCheck');
|
||||||
} else {
|
} else {
|
||||||
commit(types.CLEAR_USER);
|
commit(types.CLEAR_USER);
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+235
@@ -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',
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
|
||||||
|
import '../dashboard/assets/scss/app.scss';
|
||||||
|
import 'vue-multiselect/dist/vue-multiselect.css';
|
||||||
|
import 'floating-vue/dist/style.css';
|
||||||
|
// import tailwindStyles from '../dashboard/assets/scss/_woot.scss?inline';
|
||||||
|
|
||||||
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
|
import WootUiKit from 'dashboard/components';
|
||||||
|
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer.js';
|
||||||
|
|
||||||
|
import store from '../dashboard/store';
|
||||||
|
import constants from '../dashboard/constants/globals';
|
||||||
|
import axios from 'axios';
|
||||||
|
import createAxios from '../ui/axios';
|
||||||
|
import commonHelpers from '../dashboard/helper/commons';
|
||||||
|
import vueActionCable from '../dashboard/helper/actionCable';
|
||||||
|
|
||||||
|
import MessageList from '../ui/MessageList.vue';
|
||||||
|
import en from '../dashboard/i18n/locale/en';
|
||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const init = async () => {
|
||||||
|
commonHelpers();
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
window.__CHATWOOT_STORE__ = store;
|
||||||
|
window.WootConstants = constants;
|
||||||
|
window.axios = createAxios(axios);
|
||||||
|
|
||||||
|
return store.dispatch('setUser').then(() => {
|
||||||
|
const app = createApp(MessageList);
|
||||||
|
|
||||||
|
app.use(store);
|
||||||
|
app.use(i18n);
|
||||||
|
app.use(WootUiKit);
|
||||||
|
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof window.chatwootCallback === 'function') {
|
||||||
|
window.chatwootCallback(init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// scss-lint:disable SpaceAfterPropertyColon
|
||||||
@import 'tailwindcss/base';
|
@import 'tailwindcss/base';
|
||||||
@import 'tailwindcss/components';
|
@import 'tailwindcss/components';
|
||||||
@import 'tailwindcss/utilities';
|
@import 'tailwindcss/utilities';
|
||||||
@@ -7,7 +8,21 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
font-family: 'InterDisplay', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
font-family:
|
||||||
|
'InterDisplay',
|
||||||
|
-apple-system,
|
||||||
|
system-ui,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
'Segoe UI',
|
||||||
|
Roboto,
|
||||||
|
'Helvetica Neue',
|
||||||
|
Tahoma,
|
||||||
|
Arial,
|
||||||
|
sans-serif,
|
||||||
|
'Noto Sans',
|
||||||
|
'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji',
|
||||||
|
'Noto Color Emoji';
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default {
|
|||||||
}
|
}
|
||||||
if (el.tag === 'h2') {
|
if (el.tag === 'h2') {
|
||||||
if (this.h1Count > 0) {
|
if (this.h1Count > 0) {
|
||||||
return 'ml-2';
|
return 'ltr:ml-2 rtl:mr-2';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export default {
|
|||||||
if (!this.h1Count && !this.h2Count) {
|
if (!this.h1Count && !this.h2Count) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return 'ml-5';
|
return 'ltr:ml-5 rtl:mr-5';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
@@ -94,17 +94,19 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="hidden lg:block flex-1 py-6 scroll-mt-24 pl-4 sticky top-24">
|
<div
|
||||||
|
class="hidden lg:block flex-1 py-6 scroll-mt-24 ltr:pl-4 rtl:pr-4 sticky top-24"
|
||||||
|
>
|
||||||
<div v-if="rows.length > 0" class="py-2 overflow-auto">
|
<div v-if="rows.length > 0" class="py-2 overflow-auto">
|
||||||
<nav class="max-w-2xl">
|
<nav class="max-w-2xl">
|
||||||
<ol
|
<ol
|
||||||
role="list"
|
role="list"
|
||||||
class="flex flex-col gap-2 text-base border-l-2 border-solid border-slate-100 dark:border-slate-800"
|
class="flex flex-col gap-2 text-base ltr:border-l-2 rtl:border-r-2 border-solid border-slate-100 dark:border-slate-800"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="element in rows"
|
v-for="element in rows"
|
||||||
:key="element.slug"
|
:key="element.slug"
|
||||||
class="leading-6 border-l-2 relative -left-0.5 border-solid"
|
class="leading-6 ltr:border-l-2 rtl:border-r-2 relative ltr:-left-0.5 rtl:-right-0.5 border-solid"
|
||||||
:class="elementBorderStyles(element)"
|
:class="elementBorderStyles(element)"
|
||||||
>
|
>
|
||||||
<p class="py-1 px-3" :class="getClassName(element)">
|
<p class="py-1 px-3" :class="getClassName(element)">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import slugifyWithCounter from '@sindresorhus/slugify';
|
|||||||
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
import PublicArticleSearch from './components/PublicArticleSearch.vue';
|
||||||
import TableOfContents from './components/TableOfContents.vue';
|
import TableOfContents from './components/TableOfContents.vue';
|
||||||
import { initializeTheme } from './portalThemeHelper.js';
|
import { initializeTheme } from './portalThemeHelper.js';
|
||||||
|
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||||
|
|
||||||
export const getHeadingsfromTheArticle = () => {
|
export const getHeadingsfromTheArticle = () => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -114,10 +115,19 @@ export const InitializationHelpers = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setDirectionAttribute: () => {
|
||||||
|
const portalElement = document.getElementById('portal');
|
||||||
|
if (!portalElement) return;
|
||||||
|
|
||||||
|
const locale = document.querySelector('.locale-switcher')?.value;
|
||||||
|
portalElement.dir = locale && getLanguageDirection(locale) ? 'rtl' : 'ltr';
|
||||||
|
},
|
||||||
|
|
||||||
initializeThemesInPortal: initializeTheme,
|
initializeThemesInPortal: initializeTheme,
|
||||||
|
|
||||||
initialize: () => {
|
initialize: () => {
|
||||||
openExternalLinksInNewTab();
|
openExternalLinksInNewTab();
|
||||||
|
InitializationHelpers.setDirectionAttribute();
|
||||||
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
|
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
|
||||||
InitializationHelpers.appendPlainParamToURLs();
|
InitializationHelpers.appendPlainParamToURLs();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Chatwoot React Components
|
||||||
|
|
||||||
|
This module exports Chatwoot UI components as React components that can be embedded in other applications. It uses a hybrid approach combining Vue Web Components with React wrappers.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The system works by:
|
||||||
|
1. **Vue Components** → Compiled as custom web components using Vue's `customElement: true`
|
||||||
|
2. **React Wrappers** → Provide React-friendly interfaces to the Vue web components
|
||||||
|
3. **Vite Build** → Packages everything into distributable ES/CJS modules
|
||||||
|
|
||||||
|
## Build System
|
||||||
|
|
||||||
|
### Build Modes (vite.config.ts)
|
||||||
|
- **`BUILD_MODE=react-components`** - Builds React components library
|
||||||
|
- Entry point: `./app/javascript/react-components/src/index.jsx`
|
||||||
|
- Output: ES modules (`react-components.es.js`) and CommonJS (`react-components.cjs.js`)
|
||||||
|
- External dependencies: `react` and `react-dom` (peer dependencies)
|
||||||
|
|
||||||
|
### Available Scripts (package.json)
|
||||||
|
```bash
|
||||||
|
# Build the React components once
|
||||||
|
pnpm build:react
|
||||||
|
|
||||||
|
# Build and watch for changes during development
|
||||||
|
pnpm dev:react
|
||||||
|
|
||||||
|
# Package for NPM distribution (builds + creates dist/react-components/)
|
||||||
|
pnpm package:react
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
In any React app, version 16+, you could use the following snippet
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import * as ChatwootComponents from '@chatwoot/agent-react-components';
|
||||||
|
import '@chatwoot/agent-react-components/style.css'
|
||||||
|
|
||||||
|
const { ChatwootProvider, ChatwootConversation } = ChatwootComponents;
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ChatwootProvider
|
||||||
|
baseURL="http://localhost:3000"
|
||||||
|
accountId="1"
|
||||||
|
conversationId={13918}
|
||||||
|
userToken="XLwgvCQiewUJMGuYpZqa4J3M"
|
||||||
|
pubsubToken="HkAhoq8aWm2ecdY2tn9FV4BU"
|
||||||
|
websocketURL="ws://localhost:3000"
|
||||||
|
>
|
||||||
|
<ChatwootConversation/>
|
||||||
|
</ChatwootProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
```
|
||||||
|
|
||||||
|
The `<ChatwootProvider>` component ensures all the data necessary for mounting the web component is present before rendering the ChatwootConversation.
|
||||||
|
|
||||||
|
The package also exposes a `useChatwoot` hook for accessing the config instance being used by the conversation, it can be used as a read-only source of truth for the config being used.
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
To test this package, you can create a separate React application where you can use the snippet from above, run the following command in your Rails App:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm dev:react
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start a development server that serves the React components and their dependencies.
|
||||||
|
|
||||||
|
Following this, in a separate terminal, run the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
watchexec --restart -N --watch public/packs/react-components.es.js "pnpm package:react --copyMode"
|
||||||
|
```
|
||||||
|
|
||||||
|
This will ensure that the built files are copied correctly to the dist/react-components folder.
|
||||||
|
|
||||||
|
The `package:react` script has a copy mode, which just copies the build from the `public/` folder to the dist folder for linking purposes. It also creates a package.json file with proper entry points, ensure you run the script without copyMode once before.
|
||||||
|
|
||||||
|
Once done, in your React app, you can use `pnpm link <rails-app-dir>/dist/react-components` to link the package. Running the development server will automatically link the package and any changes to it.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
The `scripts/package-react-components.js` handles:
|
||||||
|
- Building the components with Vite
|
||||||
|
- Copying built files to `dist/react-components/`
|
||||||
|
- Generating `package.json` with proper entry points
|
||||||
|
- Creating development versions with git hash suffixes
|
||||||
|
|
||||||
|
Once the script is packaged, you can run `npm publish` to publish the package. It will be published as `@chatwoot/agent-react-components` with a version that is the latest commit hash
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useChatwoot } from './ChatwootProvider';
|
||||||
|
import { ChatwootMessageListWrapper } from './ChatwootMessageListWrapper';
|
||||||
|
|
||||||
|
export const ChatwootConversation = ({
|
||||||
|
conversationId,
|
||||||
|
className = '',
|
||||||
|
style = {},
|
||||||
|
...otherProps
|
||||||
|
}) => {
|
||||||
|
// Ensure we're inside a ChatwootProvider
|
||||||
|
useChatwoot(); // This will throw if not in Provider context
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`chatwoot-conversation ${className} text-n-slate-11`}
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChatwootMessageListWrapper className="h-full w-full" {...otherProps} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export const ChatwootMessageListWrapper = ({
|
||||||
|
conversationId,
|
||||||
|
className = '',
|
||||||
|
style = {},
|
||||||
|
onError = null,
|
||||||
|
onLoad = null,
|
||||||
|
...otherProps
|
||||||
|
}) => {
|
||||||
|
const elementRef = useRef();
|
||||||
|
|
||||||
|
// Update Web Component props when React props change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current) return;
|
||||||
|
|
||||||
|
const element = elementRef.current;
|
||||||
|
|
||||||
|
// Set conversation ID on the Web Component
|
||||||
|
element.conversationId = conversationId;
|
||||||
|
|
||||||
|
}, [conversationId]);
|
||||||
|
|
||||||
|
// Handle Web Component events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!elementRef.current) return;
|
||||||
|
|
||||||
|
const element = elementRef.current;
|
||||||
|
|
||||||
|
const handleLoad = () => {
|
||||||
|
onLoad?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = (event) => {
|
||||||
|
const errorMessage = event.detail?.message || 'Unknown error occurred';
|
||||||
|
onError?.(new Error(errorMessage));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom events from the Web Component
|
||||||
|
element.addEventListener('chatwoot:loaded', handleLoad);
|
||||||
|
element.addEventListener('chatwoot:error', handleError);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('chatwoot:loaded', handleLoad);
|
||||||
|
element.removeEventListener('chatwoot:error', handleError);
|
||||||
|
};
|
||||||
|
}, [onLoad, onError]);
|
||||||
|
|
||||||
|
// Render the Web Component
|
||||||
|
// Global setup is handled by ChatwootProvider
|
||||||
|
return (
|
||||||
|
<chatwoot-message-list
|
||||||
|
ref={elementRef}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||||
|
import { registerVueWebComponents } from '../vue-components/registerWebComponents';
|
||||||
|
import store from '../../../dashboard/store';
|
||||||
|
import constants from '../../../dashboard/constants/globals';
|
||||||
|
import axios from 'axios';
|
||||||
|
import createAxios from '../../../ui/axios';
|
||||||
|
import commonHelpers from '../../../dashboard/helper/commons';
|
||||||
|
import vueActionCable from '../../../dashboard/helper/actionCable';
|
||||||
|
|
||||||
|
const ChatwootContext = createContext();
|
||||||
|
|
||||||
|
export const ChatwootProvider = ({
|
||||||
|
baseURL,
|
||||||
|
accountId,
|
||||||
|
userToken,
|
||||||
|
websocketURL,
|
||||||
|
pubsubToken,
|
||||||
|
conversationId,
|
||||||
|
disableUpload,
|
||||||
|
disableEditor,
|
||||||
|
disablePrivateNote,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const isInitialized = useRef(false);
|
||||||
|
const [initializationComplete, setInitializationComplete] = useState(false);
|
||||||
|
|
||||||
|
// Validate required props
|
||||||
|
if (!baseURL) {
|
||||||
|
throw new Error('ChatwootProvider: baseURL is required');
|
||||||
|
}
|
||||||
|
if (!userToken) {
|
||||||
|
throw new Error('ChatwootProvider: userToken is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration object passed to all child components
|
||||||
|
const config = {
|
||||||
|
baseURL: baseURL.replace(/\/$/, ''), // Remove trailing slash
|
||||||
|
userToken,
|
||||||
|
accountId: Number(accountId),
|
||||||
|
conversationId: Number(conversationId),
|
||||||
|
disableEditor: disableEditor || false,
|
||||||
|
disablePrivateNote: disablePrivateNote || false,
|
||||||
|
disableUpload: disableUpload || false,
|
||||||
|
websocketURL: websocketURL,
|
||||||
|
pubsubToken: pubsubToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
function initializeChatwootGlobals() {
|
||||||
|
// Register Web Components
|
||||||
|
registerVueWebComponents();
|
||||||
|
|
||||||
|
// Set up global variables that Vue components expect
|
||||||
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
window.__WOOT_API_HOST__ = config.baseURL;
|
||||||
|
window.__WOOT_ACCOUNT_ID__ = config.accountId;
|
||||||
|
window.__WOOT_ACCESS_TOKEN__ = config.userToken;
|
||||||
|
window.__WEBSOCKET_URL__ = config.websocketURL;
|
||||||
|
window.__PUBSUB_TOKEN__ = config.pubsubToken;
|
||||||
|
window.__WOOT_CONVERSATION_ID__ = config.conversationId;
|
||||||
|
window.__EDITOR_DISABLE_UPLOAD__ = config.disableUpload;
|
||||||
|
window.__EDITOR_DISABLE_PRIVATE_NOTE__ = config.disablePrivateNote;
|
||||||
|
window.__DISABLE_EDITOR__ = config.disableEditor;
|
||||||
|
window.__WOOT_ISOLATED_SHELL__ = true;
|
||||||
|
/* eslint-enable no-underscore-dangle */
|
||||||
|
|
||||||
|
// Initialize common helpers
|
||||||
|
commonHelpers();
|
||||||
|
|
||||||
|
// Set up global objects
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
window.WootConstants = constants;
|
||||||
|
window.axios = createAxios(axios);
|
||||||
|
|
||||||
|
// Initialize user in store and ActionCable
|
||||||
|
store.dispatch('setUser').then(() => {
|
||||||
|
vueActionCable.init(store, config.pubsubToken);
|
||||||
|
setInitializationComplete(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isInitialized.current) return;
|
||||||
|
|
||||||
|
initializeChatwootGlobals();
|
||||||
|
isInitialized.current = true;
|
||||||
|
}, [
|
||||||
|
config.baseURL,
|
||||||
|
config.userToken,
|
||||||
|
config.websocketURL,
|
||||||
|
config.pubsubToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!initializationComplete) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatwootContext.Provider value={config}>
|
||||||
|
{children}
|
||||||
|
</ChatwootContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useChatwoot = () => {
|
||||||
|
const context = useContext(ChatwootContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useChatwoot must be used within ChatwootProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// For backwards compatibility and explicit configuration access
|
||||||
|
export const useChawootConfig = useChatwoot;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// Export all React components for the Chatwoot React Components library
|
||||||
|
|
||||||
|
// Main API components
|
||||||
|
export { ChatwootProvider, useChatwoot } from './components/ChatwootProvider';
|
||||||
|
export { ChatwootConversation } from './components/ChatwootConversation';
|
||||||
|
|
||||||
|
// Lower-level components (advanced usage)
|
||||||
|
export { ChatwootMessageListWrapper } from './components/ChatwootMessageListWrapper';
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import MessageList from '../../../ui/MessageList.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MessageList />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import '../../../dashboard/assets/scss/app.scss';
|
||||||
|
@import 'vue-multiselect/dist/vue-multiselect.css';
|
||||||
|
@import 'floating-vue/dist/style.css';
|
||||||
|
|
||||||
|
.file-uploads {
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-uploads.file-uploads-html4 input,
|
||||||
|
.file-uploads.file-uploads-html5 label {
|
||||||
|
/* background fix ie click */
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
font-size: 20em;
|
||||||
|
z-index: 1;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-uploads.file-uploads-html5 input,
|
||||||
|
.file-uploads.file-uploads-html4 label {
|
||||||
|
/* background fix ie click */
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, 0);
|
||||||
|
overflow: hidden;
|
||||||
|
position: fixed;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { defineCustomElement } from 'vue';
|
||||||
|
import ChatwootMessageListWebComponent from './ChatwootMessageListWebComponent.vue';
|
||||||
|
import '../../../dashboard/assets/scss/app.scss';
|
||||||
|
import en from '../../../dashboard/i18n/locale/en';
|
||||||
|
import store from '../../../dashboard/store';
|
||||||
|
import vueActionCable from '../../../dashboard/helper/actionCable';
|
||||||
|
import { createI18n, I18nInjectionKey } from 'vue-i18n';
|
||||||
|
|
||||||
|
import VueDOMPurifyHTML from 'vue-dompurify-html';
|
||||||
|
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
messages: {
|
||||||
|
en,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const ceOptions = {
|
||||||
|
configureApp(app) {
|
||||||
|
app.use(store);
|
||||||
|
app.use(VueDOMPurifyHTML, domPurifyConfig);
|
||||||
|
|
||||||
|
// I18n has to be injected inside that can be picked
|
||||||
|
// up by the compononent, the API stays the same, just use `useI18n`
|
||||||
|
// https://vue-i18n.intlify.dev/guide/advanced/wc
|
||||||
|
// Adding this link for my goldfish brain
|
||||||
|
app.use(i18n);
|
||||||
|
app.provide(I18nInjectionKey, i18n);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
vueActionCable.init(store, window.__PUBSUB_TOKEN__);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert Vue components to Web Components
|
||||||
|
const ChatwootMessageListElement = defineCustomElement(
|
||||||
|
ChatwootMessageListWebComponent,
|
||||||
|
ceOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register Web Components
|
||||||
|
export const registerVueWebComponents = () => {
|
||||||
|
if (!customElements.get('chatwoot-message-list')) {
|
||||||
|
customElements.define('chatwoot-message-list', ChatwootMessageListElement);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ Registered chatwoot-message-list Web Component');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export for manual registration if needed
|
||||||
|
export { ChatwootMessageListElement };
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<script>
|
||||||
|
import { useTemplateRef } from 'vue';
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||||
|
|
||||||
|
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||||
|
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||||
|
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||||
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||||
|
|
||||||
|
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||||
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
|
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
AttachmentPreview,
|
||||||
|
ReplyTopPanel,
|
||||||
|
ReplyBottomPanel,
|
||||||
|
WootMessageEditor,
|
||||||
|
},
|
||||||
|
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||||
|
setup() {
|
||||||
|
const replyEditor = useTemplateRef('replyEditor');
|
||||||
|
|
||||||
|
return { replyEditor };
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
message: '',
|
||||||
|
isFocused: false,
|
||||||
|
attachedFiles: [],
|
||||||
|
isUploading: false,
|
||||||
|
replyType: REPLY_EDITOR_MODES.REPLY,
|
||||||
|
hasSlashCommand: false,
|
||||||
|
doAutoSaveDraft: () => {},
|
||||||
|
updateEditorSelectionWith: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
...mapGetters({
|
||||||
|
currentChat: 'getSelectedChat',
|
||||||
|
currentUser: 'getCurrentUser',
|
||||||
|
globalConfig: 'globalConfig/get',
|
||||||
|
}),
|
||||||
|
currentContact() {
|
||||||
|
return this.$store.getters['contacts/getContact'](
|
||||||
|
this.currentChat.meta.sender.id
|
||||||
|
);
|
||||||
|
},
|
||||||
|
showRichContentEditor() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
isPrivate() {
|
||||||
|
// if the current chat is not loaded, assume we can reply
|
||||||
|
// this avoids rendering the editor with is-private yellow bg
|
||||||
|
// optimisitaclly defaulting to reply editor
|
||||||
|
if (!this.currentChat || !Object.keys(this.currentChat).length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentChat.can_reply) {
|
||||||
|
return this.isOnPrivateNote;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
inboxId() {
|
||||||
|
return this.currentChat.inbox_id;
|
||||||
|
},
|
||||||
|
inbox() {
|
||||||
|
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||||
|
},
|
||||||
|
messagePlaceHolder() {
|
||||||
|
return this.isPrivate
|
||||||
|
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||||
|
: this.$t('CONVERSATION.FOOTER.MSG_INPUT');
|
||||||
|
},
|
||||||
|
isMessageLengthReachingThreshold() {
|
||||||
|
return this.message.length > this.maxLength - 50;
|
||||||
|
},
|
||||||
|
charactersRemaining() {
|
||||||
|
return this.maxLength - this.message.length;
|
||||||
|
},
|
||||||
|
isReplyButtonDisabled() {
|
||||||
|
if (this.hasAttachments || this.hasRecordedAudio) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.isMessageEmpty ||
|
||||||
|
this.message.length === 0 ||
|
||||||
|
this.message.length > this.maxLength
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sender() {
|
||||||
|
return {
|
||||||
|
name: this.currentUser.name,
|
||||||
|
thumbnail: this.currentUser.avatar_url,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
conversationType() {
|
||||||
|
const { additional_attributes: additionalAttributes } = this.currentChat;
|
||||||
|
const type = additionalAttributes ? additionalAttributes.type : '';
|
||||||
|
return type || '';
|
||||||
|
},
|
||||||
|
maxLength() {
|
||||||
|
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||||
|
},
|
||||||
|
allowPrivateNote() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__EDITOR_DISABLE_PRIVATE_NOTE__ !== true;
|
||||||
|
},
|
||||||
|
isEditorDisabled() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__DISABLE_EDITOR__;
|
||||||
|
},
|
||||||
|
allowFileUpload() {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__EDITOR_DISABLE_UPLOAD__ !== true;
|
||||||
|
},
|
||||||
|
replyButtonLabel() {
|
||||||
|
if (this.isPrivate) {
|
||||||
|
return this.$t('CONVERSATION.REPLYBOX.CREATE');
|
||||||
|
}
|
||||||
|
return this.$t('CONVERSATION.REPLYBOX.SEND');
|
||||||
|
},
|
||||||
|
replyBoxClass() {
|
||||||
|
return {
|
||||||
|
'is-private': this.isPrivate && this.allowPrivateNote,
|
||||||
|
'is-focused': this.isFocused || this.hasAttachments,
|
||||||
|
'pointer-events-none grayscale opacity-70': this.isEditorDisabled,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasAttachments() {
|
||||||
|
return this.attachedFiles.length;
|
||||||
|
},
|
||||||
|
isOnPrivateNote() {
|
||||||
|
if (!this.allowPrivateNote) return false;
|
||||||
|
return this.replyType === REPLY_EDITOR_MODES.NOTE;
|
||||||
|
},
|
||||||
|
isOnExpandedLayout() {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
isMessageEmpty() {
|
||||||
|
if (!this.message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !this.message.trim().replace(/\n/g, '').length;
|
||||||
|
},
|
||||||
|
showReplyHead() {
|
||||||
|
return !this.isOnPrivateNote;
|
||||||
|
},
|
||||||
|
enableMultipleFileUpload() {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
editorMessageKey() {
|
||||||
|
const { editor_message_key: isEnabled } = this.uiSettings;
|
||||||
|
return isEnabled;
|
||||||
|
},
|
||||||
|
conversationId() {
|
||||||
|
return this.currentChat.id;
|
||||||
|
},
|
||||||
|
editorStateId() {
|
||||||
|
return `draft-${this.conversationId}-${this.replyType}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
document.addEventListener('paste', this.onPaste);
|
||||||
|
document.addEventListener('keydown', this.handleKeyEvents);
|
||||||
|
|
||||||
|
// // A hacky fix to solve the drag and drop
|
||||||
|
// // Is showing on top of new conversation modal drag and drop
|
||||||
|
// // TODO need to find a better solution
|
||||||
|
// emitter.on(
|
||||||
|
// BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||||
|
// this.onNewConversationModalActive
|
||||||
|
// );
|
||||||
|
// emitter.on(BUS_EVENTS.INSERT_INTO_NORMAL_EDITOR, this.addIntoEditor);
|
||||||
|
},
|
||||||
|
unmounted() {
|
||||||
|
document.removeEventListener('paste', this.onPaste);
|
||||||
|
document.removeEventListener('keydown', this.handleKeyEvents);
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getElementToBind() {
|
||||||
|
return this.replyEditor;
|
||||||
|
},
|
||||||
|
onPaste(e) {
|
||||||
|
const data = e.clipboardData.files;
|
||||||
|
if (!this.showRichContentEditor && data.length !== 0) {
|
||||||
|
this.$refs.messageInput.$el.blur();
|
||||||
|
}
|
||||||
|
if (!data.length || !data[0]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.forEach(file => {
|
||||||
|
const { name, type, size } = file;
|
||||||
|
this.onFileUpload({ name, type, size, file: file });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
confirmOnSendReply() {
|
||||||
|
if (this.isReplyButtonDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.showMentions) {
|
||||||
|
const messagePayload = this.getMessagePayload(this.message);
|
||||||
|
this.sendMessage(messagePayload);
|
||||||
|
this.clearMessage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async onSendReply() {
|
||||||
|
this.confirmOnSendReply();
|
||||||
|
},
|
||||||
|
async sendMessage(messagePayload) {
|
||||||
|
try {
|
||||||
|
await this.$store.dispatch(
|
||||||
|
'createPendingMessageAndSend',
|
||||||
|
messagePayload
|
||||||
|
);
|
||||||
|
// emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||||
|
// emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||||
|
if (!this.allowPrivateNote) return;
|
||||||
|
|
||||||
|
const { can_reply: canReply } = this.currentChat;
|
||||||
|
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
if (canReply) this.replyType = mode;
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
// This block addresses a scrolling issue on iOS Safari when the virtual
|
||||||
|
// keyboard opens and can obscure the focused ProseMirror editor.
|
||||||
|
// This behavior is primarily an iOS Safari quirk related to how it handles
|
||||||
|
// the viewport with the virtual keyboard. It's not something we
|
||||||
|
// directly control or can easily fix at the component levels.
|
||||||
|
//
|
||||||
|
// `this.$nextTick()`: Ensures this code runs after Vue has finished its
|
||||||
|
// DOM update cycle. This is crucial if the editor's visibility or focus
|
||||||
|
// was just programmatically changed, guaranteeing we operate on the
|
||||||
|
// finalized DOM.
|
||||||
|
//
|
||||||
|
// `setTimeout(() => { ... }, 300)`: A delay is necessary because iOS
|
||||||
|
// Safari needs time for the virtual keyboard to fully animate into view
|
||||||
|
// and for the page layout to adjust. Attempting to scroll immediately
|
||||||
|
// (even after $nextTick) often fails as the keyboard isn't yet fully
|
||||||
|
// present or the viewport dimensions haven't updated. The 300ms is a
|
||||||
|
// common heuristic.
|
||||||
|
//
|
||||||
|
// The `scrollIntoView()` method then attempts to bring the editor
|
||||||
|
// into the visible part of the viewport. This manual scroll is a
|
||||||
|
// pragmatic workaround for this specific iOS Safari behavior.
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelector('.ProseMirror')?.scrollIntoView();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.showRichContentEditor) {
|
||||||
|
if (this.isRecordingAudio) {
|
||||||
|
this.toggleAudioRecorder();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.$nextTick(() => this.$refs.messageInput.focus());
|
||||||
|
},
|
||||||
|
clearEditorSelection() {
|
||||||
|
this.updateEditorSelectionWith = '';
|
||||||
|
},
|
||||||
|
insertIntoTextEditor(text, selectionStart, selectionEnd) {
|
||||||
|
const { message } = this;
|
||||||
|
const newMessage =
|
||||||
|
message.slice(0, selectionStart) +
|
||||||
|
text +
|
||||||
|
message.slice(selectionEnd, message.length);
|
||||||
|
this.message = newMessage;
|
||||||
|
},
|
||||||
|
addIntoEditor(content) {
|
||||||
|
if (this.showRichContentEditor) {
|
||||||
|
this.updateEditorSelectionWith = content;
|
||||||
|
this.onFocus();
|
||||||
|
}
|
||||||
|
if (!this.showRichContentEditor) {
|
||||||
|
const { selectionStart, selectionEnd } = this.$refs.messageInput.$el;
|
||||||
|
this.insertIntoTextEditor(content, selectionStart, selectionEnd);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearMessage() {
|
||||||
|
this.message = '';
|
||||||
|
this.attachedFiles = [];
|
||||||
|
this.isRecordingAudio = false;
|
||||||
|
},
|
||||||
|
onTypingOn() {
|
||||||
|
this.toggleTyping('on');
|
||||||
|
},
|
||||||
|
onTypingOff() {
|
||||||
|
this.toggleTyping('off');
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
|
this.isFocused = false;
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
this.isFocused = true;
|
||||||
|
},
|
||||||
|
toggleTyping(status) {
|
||||||
|
const conversationId = this.currentChat.id;
|
||||||
|
const isPrivate = this.isPrivate;
|
||||||
|
|
||||||
|
if (!conversationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.dispatch('conversationTypingStatus/toggleTyping', {
|
||||||
|
status,
|
||||||
|
conversationId,
|
||||||
|
isPrivate,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
attachFile({ blob, file }) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file.file);
|
||||||
|
reader.onloadend = () => {
|
||||||
|
this.attachedFiles.push({
|
||||||
|
currentChatId: this.currentChat.id,
|
||||||
|
resource: blob || file,
|
||||||
|
isPrivate: this.isPrivate,
|
||||||
|
thumb: reader.result,
|
||||||
|
blobSignedId: blob ? blob.signed_id : undefined,
|
||||||
|
isRecordedAudio: file?.isRecordedAudio || false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
},
|
||||||
|
removeAttachment(attachments) {
|
||||||
|
this.attachedFiles = attachments;
|
||||||
|
},
|
||||||
|
setReplyToInPayload(payload) {
|
||||||
|
if (this.inReplyTo?.id) {
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
contentAttributes: {
|
||||||
|
...payload.contentAttributes,
|
||||||
|
in_reply_to: this.inReplyTo.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
getMessagePayload(message) {
|
||||||
|
let messagePayload = {
|
||||||
|
conversationId: this.currentChat.id,
|
||||||
|
message,
|
||||||
|
private: this.isPrivate,
|
||||||
|
sender: this.sender,
|
||||||
|
};
|
||||||
|
messagePayload = this.setReplyToInPayload(messagePayload);
|
||||||
|
|
||||||
|
if (this.attachedFiles && this.attachedFiles.length) {
|
||||||
|
messagePayload.files = [];
|
||||||
|
this.attachedFiles.forEach(attachment => {
|
||||||
|
if (this.globalConfig.directUploadsEnabled) {
|
||||||
|
messagePayload.files.push(attachment.blobSignedId);
|
||||||
|
} else {
|
||||||
|
messagePayload.files.push(attachment.resource.file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return messagePayload;
|
||||||
|
},
|
||||||
|
getKeyboardEvents() {
|
||||||
|
return {
|
||||||
|
'$mod+Enter': {
|
||||||
|
action: this.onSendReply,
|
||||||
|
allowOnFocusedInput: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||||
|
<ReplyTopPanel
|
||||||
|
v-if="allowPrivateNote"
|
||||||
|
:mode="replyType"
|
||||||
|
disable-popout
|
||||||
|
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||||
|
:characters-remaining="charactersRemaining"
|
||||||
|
@set-reply-mode="setReplyMode"
|
||||||
|
/>
|
||||||
|
<div class="reply-box__top">
|
||||||
|
<WootMessageEditor
|
||||||
|
v-model="message"
|
||||||
|
:editor-id="editorStateId"
|
||||||
|
:disabled="isEditorDisabled"
|
||||||
|
class="input"
|
||||||
|
:is-private="isOnPrivateNote"
|
||||||
|
:placeholder="messagePlaceHolder"
|
||||||
|
:update-selection-with="updateEditorSelectionWith"
|
||||||
|
:min-height="4"
|
||||||
|
enable-variables
|
||||||
|
:variables="messageVariables"
|
||||||
|
:signature="signatureToApply"
|
||||||
|
allow-signature
|
||||||
|
:channel-type="channelType"
|
||||||
|
@typing-off="onTypingOff"
|
||||||
|
@typing-on="onTypingOn"
|
||||||
|
@focus="onFocus"
|
||||||
|
@blur="onBlur"
|
||||||
|
@toggle-user-mention="toggleUserMention"
|
||||||
|
@toggle-canned-menu="toggleCannedMenu"
|
||||||
|
@toggle-variables-menu="toggleVariablesMenu"
|
||||||
|
@clear-selection="clearEditorSelection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="hasAttachments && !showAudioRecorderEditor"
|
||||||
|
class="attachment-preview-box"
|
||||||
|
@paste="onPaste"
|
||||||
|
>
|
||||||
|
<AttachmentPreview
|
||||||
|
class="flex-col mt-4"
|
||||||
|
:attachments="attachedFiles"
|
||||||
|
@remove-attachment="removeAttachment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ReplyBottomPanel
|
||||||
|
:conversation-id="conversationId"
|
||||||
|
:inbox="inbox"
|
||||||
|
:is-on-private-note="isOnPrivateNote"
|
||||||
|
:is-send-disabled="isReplyButtonDisabled"
|
||||||
|
:mode="replyType"
|
||||||
|
:on-file-upload="onFileUpload"
|
||||||
|
:on-send="onSendReply"
|
||||||
|
:conversation-type="conversationType"
|
||||||
|
:send-button-text="replyButtonLabel"
|
||||||
|
:show-audio-recorder="showAudioRecorder"
|
||||||
|
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
|
||||||
|
:show-emoji-picker="false"
|
||||||
|
:show-file-upload="allowFileUpload"
|
||||||
|
:message="message"
|
||||||
|
:enable-multiple-file-upload="allowFileUpload"
|
||||||
|
:allow-file-upload="allowFileUpload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.send-button {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner--self-assign {
|
||||||
|
@apply py-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview-box {
|
||||||
|
@apply bg-transparent py-0 px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-box {
|
||||||
|
transition: height 2s cubic-bezier(0.37, 0, 0.63, 1);
|
||||||
|
|
||||||
|
@apply relative mb-2 mx-2 border border-n-weak rounded-xl bg-n-solid-1;
|
||||||
|
|
||||||
|
&.is-private {
|
||||||
|
@apply bg-n-solid-amber dark:border-n-amber-3/10 border-n-amber-12/5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* in safari the list marker is truncated */
|
||||||
|
/* We could add, the following, but in Safari, it behaves weirdly with the curosr */
|
||||||
|
/* ::v-deep .ProseMirror li {
|
||||||
|
list-style-position: inside !important;
|
||||||
|
} */
|
||||||
|
|
||||||
|
@supports (-webkit-hyphens: none) {
|
||||||
|
::v-deep .ProseMirror ul {
|
||||||
|
margin-left: 2ch !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
::v-deep .ProseMirror ol {
|
||||||
|
margin-left: 2ch !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-button {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-box__top {
|
||||||
|
@apply relative py-0 px-4 -mt-px;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
@apply shadow-none outline-none border-transparent bg-transparent m-0 max-h-60 min-h-[3rem] pt-4 pb-0 px-0 resize-none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-dialog {
|
||||||
|
@apply top-[unset] -bottom-10 -left-80 right-[unset];
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||||
|
@apply -right-4 bottom-2 rtl:right-0 rtl:-left-4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-dialog--rtl {
|
||||||
|
@apply left-[unset] -right-80;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-dialog--expanded {
|
||||||
|
@apply left-[unset] bottom-0 absolute z-[100];
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
@apply left-1 -bottom-2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-editor__canned-box {
|
||||||
|
width: calc(100% - 2 * var(--space-normal));
|
||||||
|
left: var(--space-normal);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, watch, useTemplateRef } from 'vue';
|
||||||
|
import Message from 'next/message/Message.vue';
|
||||||
|
import TypingIndicator from 'next/message/TypingIndicator.vue';
|
||||||
|
import Snipper from 'next/spinner/Spinner.vue';
|
||||||
|
import LiteReplyBox from './LiteReplyBox.vue';
|
||||||
|
import {
|
||||||
|
useStore,
|
||||||
|
useMapGetter,
|
||||||
|
useFunctionGetter,
|
||||||
|
//
|
||||||
|
} from '../dashboard/composables/store';
|
||||||
|
import { getTypingUsersText } from '../dashboard/helper/commons';
|
||||||
|
import { useCamelCase } from '../dashboard/composables/useTransformKeys';
|
||||||
|
import { useInfiniteScroll, useThrottleFn } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const conversationId = computed(() => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__WOOT_CONVERSATION_ID__;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const messageListRef = useTemplateRef('messageListRef');
|
||||||
|
const store = useStore();
|
||||||
|
const isAllLoaded = useMapGetter('getAllMessagesLoaded');
|
||||||
|
const isFetching = ref(false);
|
||||||
|
|
||||||
|
const typingUserList = useFunctionGetter(
|
||||||
|
'conversationTypingStatus/getUserList',
|
||||||
|
conversationId.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAnyoneTyping = computed(() => {
|
||||||
|
return typingUserList.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const typingUserNames = computed(() => {
|
||||||
|
if (!isAnyoneTyping.value) return '';
|
||||||
|
const [i18nKey, params] = getTypingUsersText(typingUserList.value);
|
||||||
|
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
|
||||||
|
return t(i18nKey, params);
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversation = computed(() => {
|
||||||
|
return store.getters.getConversationById(conversationId.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const allMessages = computed(() => {
|
||||||
|
if (!conversation.value) return [];
|
||||||
|
|
||||||
|
return useCamelCase(conversation.value.messages, { deep: true }).reverse();
|
||||||
|
});
|
||||||
|
|
||||||
|
const disablePrivateNote = computed(() => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
return window.__EDITOR_DISABLE_PRIVATE_NOTE__ === true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchMore = async () => {
|
||||||
|
if (isFetching.value) return;
|
||||||
|
if (!conversation?.value?.id) return;
|
||||||
|
if (!allMessages.value?.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isFetching.value = true;
|
||||||
|
|
||||||
|
await store.dispatch('fetchPreviousMessages', {
|
||||||
|
conversationId: conversation.value.id,
|
||||||
|
before: allMessages.value[allMessages.value.length - 1].id,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isFetching.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await store.dispatch('inboxes/get');
|
||||||
|
await Promise.all([
|
||||||
|
store.dispatch('getConversation', conversationId.value),
|
||||||
|
store.dispatch('fetchAllAttachments', conversationId.value),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(conversation, () => {
|
||||||
|
store.dispatch('setActiveChat', {
|
||||||
|
data: {
|
||||||
|
id: conversation.value.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useInfiniteScroll(messageListRef, useThrottleFn(fetchMore, 1000), {
|
||||||
|
canLoadMore: () => {
|
||||||
|
return !isAllLoaded.value;
|
||||||
|
},
|
||||||
|
distance: 10,
|
||||||
|
direction: 'top',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<ul
|
||||||
|
ref="messageListRef"
|
||||||
|
class="px-4 pt-4 flex flex-col-reverse bg-n-background overflow-scroll h-screen"
|
||||||
|
:class="{
|
||||||
|
'pb-60': !disablePrivateNote,
|
||||||
|
'pb-48': disablePrivateNote,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isAnyoneTyping"
|
||||||
|
id="conversationFooter"
|
||||||
|
class="my-2 py-2 flex items-center w-full"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex py-2 px-4 shadow-md rounded-full bg-white dark:bg-slate-700 text-n-slate-11 text-xs font-semibold mx-auto items-center gap-1"
|
||||||
|
>
|
||||||
|
{{ typingUserNames }}
|
||||||
|
<TypingIndicator class="text-n-slate-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-for="message in allMessages" :key="message.id">
|
||||||
|
<Message
|
||||||
|
v-bind="message"
|
||||||
|
:is-email-inbox="isAnEmailChannel"
|
||||||
|
:group-with-next="false"
|
||||||
|
:inbox-supports-reply-to="inboxSupportsReplyTo"
|
||||||
|
:current-user-id="currentUserId"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<div v-show="isFetching" class="w-full py-4">
|
||||||
|
<Snipper class="mx-auto" />
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
<div class="p-2 w-full bg-white absolute bottom-0">
|
||||||
|
<LiteReplyBox />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* this will disable elastic scrolling on the y axis */
|
||||||
|
* {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
const parseErrorCode = error => Promise.reject(error);
|
||||||
|
|
||||||
|
export default axios => {
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const apiHost = window.__WOOT_API_HOST__;
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const accessToken = window.__WOOT_ACCESS_TOKEN__;
|
||||||
|
const wootApi = axios.create({ baseURL: `${apiHost}/` });
|
||||||
|
|
||||||
|
Object.assign(wootApi.defaults.headers.common, {
|
||||||
|
api_access_token: accessToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
wootApi.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => parseErrorCode(error)
|
||||||
|
);
|
||||||
|
return wootApi;
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Chatwoot UI Components Usage Guide
|
||||||
|
|
||||||
|
This document explains how to use the Chatwoot UI components library, which can be used both as Web Components (Custom Elements) and as Vue components.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @chatwoot/ui
|
||||||
|
# or
|
||||||
|
yarn add @chatwoot/ui
|
||||||
|
# or
|
||||||
|
pnpm add @chatwoot/ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### As Web Components (Custom Elements)
|
||||||
|
|
||||||
|
Web Components can be used in any HTML page or application, regardless of the framework.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Include the built JS file -->
|
||||||
|
<script src="https://unpkg.com/@chatwoot/ui/dist/ui.js"></script>
|
||||||
|
|
||||||
|
<!-- Use the custom element anywhere in your HTML -->
|
||||||
|
<chat-button label="Click me"></chat-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
When the script loads, it automatically registers all custom elements with the browser, making them immediately available for use.
|
||||||
|
|
||||||
|
#### Accessing the Store in Web Components
|
||||||
|
|
||||||
|
The components have access to a global store instance. When using Web Components, the store is available through `window.__CHATWOOT_STORE__`.
|
||||||
|
|
||||||
|
You can interact with the store from your JavaScript:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Access the store
|
||||||
|
const store = window.__CHATWOOT_STORE__;
|
||||||
|
|
||||||
|
// Check store state
|
||||||
|
console.log(store.state.someData);
|
||||||
|
|
||||||
|
// Dispatch actions
|
||||||
|
store.dispatch('someAction', { data: 'example' });
|
||||||
|
```
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user