Compare commits

...
Author SHA1 Message Date
Sivin VargheseandGitHub d661fb7955 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-24 20:38:45 +05:30
7cec4ebaae feat: support multimodal user messages in captain v2 (#13581)
Extract and pass image attachments from the latest user message to the
runner,
excluding the last user message from the context for processing.

Fixes #13588 

# Pull Request Template

## Description

Adds image support to captain v2

## Type of change

Please delete options that are not relevant.

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

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

specs and local testing

<img width="754" height="1008" alt="image"
src="https://github.com/user-attachments/assets/914cbc2c-9d30-42d0-87d4-9e5430845c87"
/>

langfuse also shows media correctly with the instrumentation code:
<img width="1800" height="1260" alt="image"
src="https://github.com/user-attachments/assets/ce0f5fa6-b1a5-42ec-a213-9a82b1751037"
/>


## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:37:41 +05:30
Muhsin KelothandGitHub 6be95e79f8 feat(csat): Add WhatsApp utility template analyzer with rewrite guidance (#13575)
CSAT templates for WhatsApp are submitted as Utility, but Meta may
reclassify them as Marketing based on content, which can significantly
increase messaging costs.
This PR introduces a Captain-powered CSAT template analyzer for
WhatsApp/Twilio WhatsApp that predicts utility fit, explains likely
risks, and suggests safer rewrites before submission. The flow is manual
(button-triggered), Captain-gated, and applies rewrites only on explicit
user action. It also updates UX copy to clearly set expectations: the
system submits as Utility, Meta makes the final categorization decision.

Fixes
https://linear.app/chatwoot/issue/CW-6424/ai-powered-whatsapp-template-classifier-for-csat-submissions


https://github.com/user-attachments/assets/8fd1d6db-2f91-447c-9771-3de271b16fd9
2026-02-24 15:11:04 +04:00
Sivin VargheseandGitHub 3fba205993 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-24 16:25:59 +05:30
Tanmay Deep SharmaandGitHub 2b85275e26 feat: show assignment policy name in auto-assignment activity messages (#13598) 2026-02-24 13:32:54 +05:30
Sivin VargheseandGitHub 2a08e1d727 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-23 21:25:50 +05:30
5b167b5b5b fix(contacts): Show telegram id in contact details form (#13611)
## Summary
This change fixes a mismatch in contact details where Telegram data
could be shown in the contact profile/social icon area but was not
available in the editable contact form.

### What changed
- Added Telegram to the social links section of the next-gen contact
form so agents can view and edit it alongside Facebook, Instagram,
TikTok, Twitter, GitHub, and LinkedIn.
- Added Telegram support to the legacy conversation contact edit form
for parity between both contact editing experiences.
- Mapped social_telegram_user_name into the editable socialProfiles
payload when preparing contact form state, so Telegram usernames sourced
from channel attributes are visible in the form.
- Updated the conversation contact social profile merge logic so
Telegram display prefers an explicitly saved social profile value and
falls back to social_telegram_user_name when needed.
- Added the missing English i18n placeholder: Add Telegram.

### Why
Without this, users could see Telegram info in some contact views but
could not reliably edit it in contact details, creating inconsistent
behavior between display and edit states.

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-02-23 19:26:45 +04:00
iamsivin aae7212378 fix: Clear selected items when dismissing bulk action dropdowns 2026-02-23 18:27:04 +05:30
Sivin VargheseandGitHub 0966894794 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-23 17:22:10 +05:30
iamsivin 85aed38cc7 chore: Review fix 2026-02-23 17:19:28 +05:30
Muhsin KelothandGitHub b220663785 fix: Skip notifications for private notes (#13617)
When agents or integrations create private notes on a conversation,
every note was sending a notification to the assigned agent and all
conversation participants. The fix ensures that private notes no longer
trigger new message notifications. If someone explicitly mentions a
teammate in a private note, that person will still get notified as
expected.
2026-02-23 15:40:54 +04:00
Sivin VargheseandGitHub a6aa2c8941 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-23 17:04:26 +05:30
40da358dc2 feat: better errors for SMTP (#13401)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-23 16:00:17 +05:30
957a1b17c9 perf: add default configs for assignment V2 (#13577)
## Description

AutoAssignment::RateLimiter#within_limit? returned true early for
inboxes without an AssignmentPolicy, bypassing fair distribution
entirely and allowing unlimited conversation assignment.

## Type of change

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

## Script

- Script to enable users with V2 assignment:
https://www.notion.so/chatwoot/Script-to-migrate-account-to-assignment-V2-30ca5f274c9280f5b8ecfd15e28eeb9c?source=copy_link

## Checklist:

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

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-02-23 15:08:11 +05:30
Aakash BakhleandGitHub 9dd13b9a2b fix: topup checkout flaky test (#13616) 2026-02-23 14:54:52 +05:30
iamsivin 540135a1cb chore: Minor fix 2026-02-22 23:24:22 +05:30
iamsivin 7666b381ae chore: Minor fix 2026-02-22 22:51:58 +05:30
iamsivin 48a7388127 chore: Fix build 2026-02-22 22:29:17 +05:30
Sivin VargheseandGitHub aeb3607ac9 Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-22 22:25:49 +05:30
iamsivin 5ef4e2fd88 chore: Review fixes 2026-02-22 22:18:56 +05:30
2441487a76 perf: skip conversation loading in /meta endpoint (#13564)
# Pull Request Template

## Summary
- Adds `perform_meta_only` method to `ConversationFinder` that runs
setup and counts without loading the paginated conversation list
- Updates `/api/v1/conversations/meta` to use `perform_meta_only`
instead of `perform`

## Problem
The `/meta` endpoint calls `ConversationFinder#perform` which:
1. Runs all filters and setup (`set_up`)
2. Computes 3 COUNT queries (`set_count_for_all_conversations`)
3. Filters by assignee type
4. **Builds the full paginated conversation list** with
`.includes(:taggings, :inbox, {assignee: {avatar_attachment: [:blob]}},
{contact: {avatar_attachment: [:blob]}}, :team, :contact_inbox)` +
sorting + pagination

The controller then **discards the conversations** and only uses the
counts:
```ruby
def meta
result = conversation_finder.perform
@conversations_count = result[:count]  # conversations thrown away
end
```

## Type of change

- [x] Performance fix

## How Has This Been Tested?

- [ ] Verify /meta returns correct mine/unassigned/assigned/all counts
- [ ] Verify counts update when switching inbox, team, or status filters
- [ ] Verify conversation list still loads correctly (uses perform, not
affected)
- [ ] Monitor response time reduction for /meta in NewRelic after deploy

## Checklist:

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

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
2026-02-20 21:20:19 +05:30
Sivin VargheseandGitHub c85598fe8b Merge branch 'develop' into feat(v5)/expanded-chatlist 2026-02-20 20:31:18 +05:30
Sivin VargheseandGitHub 418bd177f8 fix: Adjust inbox settings pages layout width (#13590)
# Pull Request Template

## Description

This PR includes,

1. Adjusting the inbox settings page layout width from 3xl to 4xl for
the collaborators, configuration, and bot configuration sections.
2. Adding a dynamic max-width for inbox settings banners based on the
selected tab.
3. Making the sender name preview layout responsive.
4. Reordering automation rule row buttons so Clone appears before
Delete.
5. Update the Gmail icon ratio.
6. Fix height issues with team/inbox pages
7. The delete button changes to red on hover
8. Add border to conversation header when no dashboard apps present


## Type of change

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



## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-02-20 20:20:32 +05:30
Shivam Mishra 572f5b2709 Merge branch 'hotfix/4.11.1' into develop 2026-02-20 20:02:39 +05:30
Shivam Mishra 15f25f019e chore: bump version 2026-02-20 20:02:09 +05:30
Shivam Mishra 280ca06e5b fix: url endpoint
fix: spec
2026-02-20 20:01:14 +05:30
iamsivin de0b78e273 feat(v5): New expanded layout for chat list 2026-02-20 15:23:37 +05:30
Sojan Jose 2bd1d88d50 Merge branch 'release/4.11.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-02-17 15:40:39 -08:00
Sojan Jose 1345f67966 Merge branch 'release/4.10.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-20 08:44:14 -08:00
Sojan Jose 0f914fa2ab Merge branch 'release/4.10.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-01-15 22:20:22 -08:00
Sojan Jose 59663dd558 Merge branch 'hotfix/4.9.2'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
2026-01-12 09:15:17 -08:00
128 changed files with 5112 additions and 2506 deletions
+1 -1
View File
@@ -191,7 +191,7 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents'
gem 'ai-agents', '>= 0.9.1'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
+2 -2
View File
@@ -126,7 +126,7 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.0)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
@@ -1024,7 +1024,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents
ai-agents (>= 0.9.1)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
+1 -1
View File
@@ -1 +1 @@
4.11.0
4.11.1
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
result = conversation_finder.perform
result = conversation_finder.perform_meta_only
@conversations_count = result[:count]
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -24,6 +25,23 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -46,6 +64,12 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
+16
View File
@@ -55,6 +55,22 @@ class ConversationFinder
}
end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private
def set_up
+17 -21
View File
@@ -57,39 +57,35 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_enable_tls(channel_data, smtp)
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
elsif channel_data[:smtp_enable_starttls_auto]
set_enable_starttls_auto(channel_data, smtp)
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
end
end
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(method)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context ? smtp.send(method, context) : smtp.send(method)
end
def enable_openssl_mode(smtp_openssl_verify_mode)
+6
View File
@@ -42,6 +42,12 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
}
export default new Inboxes();
@@ -44,6 +44,7 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
@@ -66,6 +67,7 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
telegram: '',
tiktok: '',
linkedin: '',
twitter: '',
@@ -103,9 +105,13 @@ const prepareStateBasedOnProps = () => {
countryCode = '',
country = '',
city = '',
socialTelegramUserName = '',
socialProfiles = {},
} = additionalAttributes || {};
const telegramUsername =
socialProfiles?.telegram || socialTelegramUserName || '';
Object.assign(state, {
id,
name,
@@ -119,7 +125,10 @@ const prepareStateBasedOnProps = () => {
countryCode,
country,
city,
socialProfiles,
socialProfiles: {
...socialProfiles,
telegram: telegramUsername,
},
},
});
};
@@ -0,0 +1,58 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -0,0 +1,47 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -0,0 +1,175 @@
<script setup>
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -1,207 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
defineProps({
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
priority: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
<Icon
v-tooltip.top="{
content: priority,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
</template>
@@ -0,0 +1,42 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -0,0 +1,160 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
const slaCardLabel = useTemplateRef('slaCardLabel');
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat?.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const showLabelsSection = computed(
() => props.chat.labels?.length > 0 || hasSlaPolicyId.value
);
const showMetaSection = computed(
() =>
(!props.isInboxView && props.showInboxName) ||
(props.showAssignee && props.assignee?.name) ||
props.chat.priority
);
const onSelectConversation = checked => {
emit('selectConversation', checked);
};
</script>
<template>
<div
class="relative flex items-start max-w-full cursor-pointer group transition-colors duration-150 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-2 rounded-lg': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div class="relative">
<CardAvatar
v-if="!compact && !hideThumbnail"
:contact="currentContact"
:selected="selected"
:enable-selection="enableSelection"
:hide-thumbnail="hideThumbnail"
:class="showMetaSection ? 'mt-8' : 'mt-4'"
@select-conversation="onSelectConversation"
/>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName
v-if="showInboxName && !isInboxView"
:inbox="inbox"
class="flex-1 min-w-0"
/>
<div
v-if="showAssignee || chat.priority"
class="flex items-center gap-2 flex-shrink-0 h-4"
:class="{
'flex-1 justify-between': isInboxView || !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-label-small px-0 inline-flex items-center truncate gap-0.5 max-w-36"
>
<Icon icon="i-lucide-user-round" class="text-n-slate-11 size-3" />
{{ assignee.name }}
</span>
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
</div>
</div>
<div class="inline-flex items-center justify-between w-full">
<h4
class="text-heading-3 mx-2 capitalize pt-0.5 truncate flex-1 min-w-0 text-n-slate-12"
:class="hasUnread ? '!font-520' : ''"
>
{{ currentContact.name }}
</h4>
<span class="text-label-small text-n-slate-11">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
</div>
<div class="ltr:ml-2 rtl:mr-2 leading-6 h-6 min-w-0">
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
/>
</div>
<CardLabels
v-if="showLabelsSection"
:labels="chat.labels"
class="mt-0.5 mx-2"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel ref="slaCardLabel" :chat="chat" data-before-slot />
</template>
</CardLabels>
</div>
</div>
</template>
@@ -0,0 +1,182 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="relative cursor-pointer group transition-colors duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div v-if="!isInboxView" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,245 @@
<script setup>
import { computed, ref, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import ConversationCardExpanded from './ConversationCardExpanded.vue';
import ConversationCardCompact from './ConversationCardCompact.vue';
// import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
enableSelection: { type: Boolean, default: true },
isExpandedLayout: { type: Boolean, default: false },
isPreviousConversations: { type: Boolean, default: false },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const isLargeScreen = inject('isLargeScreen', ref(false));
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
const showExpanded = computed(() => {
return props.isExpandedLayout && isLargeScreen.value;
});
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? contactGetter.value(senderId.value) : {}
);
const hasActiveInbox = computed(() => activeInbox.value);
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
const inbox = computed(() => {
const inboxId = props.chat.inbox_id;
return inboxId ? inboxGetter.value(inboxId) : {};
});
const showInboxName = computed(() => inboxesList.value.length > 1);
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@de-select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) -->
<ConversationCardCompact
v-else
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:compact="compact"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:hide-thumbnail="hideThumbnail"
:enable-selection="enableSelection"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="chat.unread_count > 0"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -0,0 +1,69 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -0,0 +1,19 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open
() => uiSettings.value.is_contact_sidebar_open || false
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open
() => uiSettings.value.is_copilot_panel_open || false
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: true,
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
is_copilot_panel_open: !isCopilotPanelOpen.value,
});
};
@@ -0,0 +1,100 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import SLAPopoverCard from './SLAPopoverCard.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: conversation.value,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
</template>
@@ -0,0 +1,36 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const { SLA_MISS_TYPES } = wootConstants;
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('SLA.EVENTS.TITLE') }}
</span>
<SLAEventItem
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLAEventItem
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<Button
link
xs
slate
class="hover:!no-underline"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
:label="
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
"
@click="toggleShowAllNRT"
/>
</div>
</template>
</SLAEventItem>
<SLAEventItem
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>
@@ -82,7 +82,13 @@ const attributeIcon = computed(() => {
sm
@click="emit('edit', attribute)"
/>
<Button icon="i-woot-bin" slate sm @click="emit('delete', attribute)" />
<Button
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="emit('delete', attribute)"
/>
</div>
</div>
</div>
@@ -11,7 +11,13 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
},
},
menuSections: {
@@ -22,6 +28,10 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -42,9 +52,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
});
const emit = defineEmits(['action', 'search']);
const emit = defineEmits(['action', 'search', 'empty']);
const { t } = useI18n();
@@ -96,9 +114,13 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
};
const handleAction = item => {
@@ -190,22 +212,27 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
@@ -214,6 +241,9 @@ onMounted(() => {
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" />
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
@@ -233,22 +263,27 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
</template>
<div
@@ -258,7 +293,9 @@ onMounted(() => {
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
}}
</div>
<slot name="footer" />
@@ -39,38 +39,45 @@ const formatFilterValue = value => {
// Case 2: array map each item, use name if present, else the item itself
if (Array.isArray(value)) {
// Empty array
if (value.length === 0) return '';
return value.map(item => item?.name ?? item).join(', ');
}
// Case 3: object with a "name" property return name
// Case 4: primitive (string, number, etc.) return as is
// Case 3: empty object
if (typeof value === 'object' && Object.keys(value).length === 0) {
return '';
}
// Case 4: object with a "name" property return name
// Case 5: primitive (string, number, etc.) return as is
return value?.name ?? value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-2 h-7"
class="inline-flex items-center gap-1.5 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="filter.values"
v-if="formatFilterValue(filter.values)"
:title="formatFilterValue(filter.values)"
class="lowercase truncate text-n-slate-12"
class="lowercase truncate text-n-slate-12 font-440"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
@@ -86,7 +93,7 @@ const formatFilterValue = value => {
"
>
<span
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
>
{{ filter.queryOperator }}
</span>
@@ -95,7 +102,7 @@ const formatFilterValue = value => {
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
@@ -105,9 +112,10 @@ const formatFilterValue = value => {
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1"
variant="ghost"
class="!px-1 hover:!no-underline"
link
@click="emit('clearFilters')"
/>
<slot name="actions" />
</div>
</template>
@@ -1,12 +1,14 @@
<script setup>
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBreakpoints } from '@vueuse/core';
import { useTrack } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import wootConstants from 'dashboard/constants/globals';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -42,6 +44,13 @@ const DEFAULT_FILTER = {
const { t } = useI18n();
const store = useStore();
const breakpoints = useBreakpoints({
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
});
const isSmallScreen = breakpoints.smaller('mobile');
const viewInModal = computed(() => isSmallScreen.value);
const resetFilter = () => {
filters.value = [{ ...DEFAULT_FILTER }];
};
@@ -104,69 +113,80 @@ const outsideClickHandler = [
<template>
<div
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
viewInModal,
}"
@click.self="viewInModal && emit('close')"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
<div
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl py-6 grid gap-6"
:class="{ 'overflow-x-auto': viewInModal }"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12 px-6">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView" class="px-6">
<div class="border-b border-n-strong pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
</div>
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex gap-2 justify-between">
<Button sm ghost blue @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
<ul class="grid gap-4 list-none px-6">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div
class="flex gap-2 justify-between ltr:pl-3 ltr:pr-6 rtl:pr-3 rtl:pl-6"
>
<Button sm ghost blue class="px-0" @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm solid slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
</Button>
</div>
</div>
</div>
</div>
@@ -108,8 +108,10 @@ export default {
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
:message="
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
@blur="v$.name.$touch"
/>
<div class="flex flex-row justify-end w-full gap-2">
@@ -95,7 +95,7 @@ const toggleOption = option => {
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
class="bg-n-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
@@ -119,11 +119,13 @@ const toggleOption = option => {
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate faded @click="toggle">
<Button v-else sm slate solid @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11 truncate">{{
t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
+113 -633
View File
@@ -1,77 +1,34 @@
<script setup>
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
// composables and components, useVirtualChatList, useChatlistFilters
import {
ref,
unref,
provide,
computed,
watch,
onMounted,
defineEmits,
} from 'vue';
import { ref, provide, computed, watch, onMounted, defineEmits } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
import { useRouter } from 'vue-router';
import {
useMapGetter,
useFunctionGetter,
} from 'dashboard/composables/store.js';
// [VITE] [TODO] We are using vue-virtual-scroll for now, since that seemed the simplest way to migrate
// from the current one. But we should consider using tanstack virtual in the future
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ConversationList from './ConversationList.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import IntersectionObserver from './IntersectionObserver.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import ChatListFilters from './ChatListFilters.vue';
import ConversationActiveFiltersPreview from './widgets/conversation/ConversationActiveFiltersPreview.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useFilter } from 'shared/composables/useFilter';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
import { useEventListener } from '@vueuse/core';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { emitter } from 'shared/helpers/mitt';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import { conversationListPageURL } from '../helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import {
getUserPermissions,
filterItemsByPermission,
} from 'dashboard/helper/permissionsHelper.js';
import { matchesFilters } from '../store/modules/conversations/helpers/filterHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 },
@@ -85,34 +42,15 @@ const props = defineProps({
const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const conversationDynamicScroller = ref(null);
provide('contextMenuElementTarget', conversationDynamicScroller);
const router = useRouter();
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
const activeSortBy = ref(wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC);
const showAdvancedFilters = ref(false);
// chatsOnView is to store the chats that are currently visible on the screen,
// which mirrors the conversationList.
const chatsOnView = ref([]);
const foldersQuery = ref({});
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const isContextMenuOpen = ref(false);
const appliedFilter = ref([]);
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
...filter,
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const chatListFiltersRef = ref(null);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
@@ -123,18 +61,10 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const folders = useMapGetter('customViews/getConversationCustomViews');
const agentList = useMapGetter('agents/getAgents');
const teamsList = useMapGetter('teams/getTeams');
const inboxesList = useMapGetter('inboxes/getInboxes');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const labels = useMapGetter('labels/getLabels');
const currentAccountId = useMapGetter('getCurrentAccountId');
// We can't useFunctionGetter here since it needs to be called on setup?
const getTeamFn = useMapGetter('teams/getTeam');
const getConversationById = useMapGetter('getConversationById');
const folders = useMapGetter('customViews/getConversationCustomViews');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -143,63 +73,28 @@ const {
selectAllConversations,
resetBulkActions,
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const intersectionObserverOptions = computed(() => {
return {
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
};
});
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length !== 0;
});
const activeFolder = computed(() => {
if (props.foldersId) {
const activeView = folders.value.filter(
view => view.id === Number(props.foldersId)
);
const [firstValue] = activeView;
return firstValue;
}
return undefined;
});
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
const hasActiveFolders = computed(() => {
return Boolean(activeFolder.value && props.foldersId !== 0);
return Boolean(props.foldersId !== 0);
});
const activeFolder = computed(() => {
return chatListFiltersRef.value?.activeFolder;
});
const isFilterModalOpen = computed(() => {
return chatListFiltersRef.value?.showAdvancedFilters ?? false;
});
const hasAppliedFiltersOrActiveFolders = computed(() => {
return hasAppliedFilters.value || hasActiveFolders.value;
});
const currentUserDetails = computed(() => {
const { id, name } = currentUser.value;
return { id, name };
});
const userPermissions = computed(() => {
return getUserPermissions(currentUser.value, currentAccountId.value);
});
@@ -243,11 +138,6 @@ const hasCurrentPageEndReached = useFunctionGetter(
currentPageFilterKey
);
const conversationCustomAttributes = useFunctionGetter(
'attributes/getAttributesByModel',
'conversation_attribute'
);
const activeAssigneeTabCount = computed(() => {
const count = assigneeTabItems.value.find(
item => item.key === activeAssigneeTab.value
@@ -317,7 +207,7 @@ const pageTitle = computed(() => {
if (props.conversationType === 'unattended') {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value) {
if (hasActiveFolders.value && activeFolder.value?.name) {
return activeFolder.value.name;
}
return t('CHAT_LIST.TAB_HEADING');
@@ -339,7 +229,7 @@ const conversationList = computed(() => {
localConversationList = [...chatLists.value];
}
if (activeFolder.value) {
if (activeFolder.value?.query?.payload) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
@@ -350,7 +240,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return (
return !!(
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -384,205 +274,71 @@ function setFiltersFromUISettings() {
function emitConversationLoaded() {
emit('conversationLoad');
// [VITE] removing this since the library has changed
// nextTick(() => {
// // Addressing a known issue in the virtual list library where dynamically added items
// // might not render correctly. This workaround involves a slight manual adjustment
// // to the scroll position, triggering the list to refresh its rendering.
// const virtualList = conversationListRef.value;
// const scrollToOffset = virtualList?.scrollToOffset;
// const currentOffset = virtualList?.getOffset() || 0;
// if (scrollToOffset) {
// scrollToOffset(currentOffset + 1);
// }
// });
}
function fetchFilteredConversations(payload) {
payload = useSnakeCase(payload);
const transformedPayload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(payload),
queryData: filterQueryGenerator(transformedPayload),
page,
})
.then(emitConversationLoaded);
showAdvancedFilters.value = false;
}
function fetchSavedFilteredConversations(payload) {
payload = useSnakeCase(payload);
const transformedPayload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: payload,
queryData: transformedPayload,
page,
})
.then(emitConversationLoaded);
}
function onApplyFilter(payload) {
payload = useSnakeCase(payload);
resetBulkActions();
foldersQuery.value = filterQueryGenerator(payload);
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
fetchFilteredConversations(payload);
}
function closeAdvanceFiltersModal() {
showAdvancedFilters.value = false;
appliedFilter.value = [];
}
function onUpdateSavedFilter(payload, folderName) {
const transformedPayload = useSnakeCase(payload);
const payloadData = {
...unref(activeFolder),
name: unref(folderName),
query: filterQueryGenerator(transformedPayload),
};
store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
}
function onClickOpenAddFoldersModal() {
showAddFoldersModal.value = true;
}
function onCloseAddFoldersModal() {
showAddFoldersModal.value = false;
}
function onClickOpenDeleteFoldersModal() {
showDeleteFoldersModal.value = true;
}
function onCloseDeleteFoldersModal() {
showDeleteFoldersModal.value = false;
}
function setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
return {
agents: agentList.value,
teams: teamsList.value,
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
languages: languages,
countries: countries,
priority: [
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value,
};
}
function initializeExistingFilterToModal() {
const statusFilter = initializeStatusAndAssigneeFilterToModal(
activeStatus.value,
currentUserDetails.value,
activeAssigneeTab.value
);
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
if (statusFilter) {
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
}
// TODO: Remove the usage of useCamelCase after migrating useFilter to camelcase
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
props.conversationInbox,
inbox.value,
props.teamId,
activeTeam.value,
props.label
).map(useCamelCase);
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
}
function initializeFolderToFilterModal(newActiveFolder) {
// Here we are setting the params for edit folder modal.
// To show the existing values. when we click on edit folder button.
// Here we get the query from the active folder.
// And we are mapping the query to the actual values.
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
const query = unref(newActiveFolder)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditFolderModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
}
function initalizeAppliedFiltersToModal() {
appliedFilter.value = [...appliedFilters.value];
}
function onToggleAdvanceFiltersModal() {
if (showAdvancedFilters.value === true) {
closeAdvanceFiltersModal();
return;
}
if (!hasAppliedFilters.value && !hasActiveFolders.value) {
initializeExistingFilterToModal();
}
if (hasActiveFolders.value) {
initializeFolderToFilterModal(activeFolder.value);
}
if (hasAppliedFilters.value) {
initalizeAppliedFiltersToModal();
}
showAdvancedFilters.value = true;
}
function fetchConversations() {
store.dispatch('updateChatListFilters', conversationFilters.value);
store.dispatch('fetchAllConversations').then(emitConversationLoaded);
}
function onApplyFilter(payload) {
resetBulkActions();
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
fetchFilteredConversations(payload);
}
function onOpenAddFolders() {
const lastItemOfFolder = folders.value[folders.value.length - 1];
const lastItemId = lastItemOfFolder.id;
router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
}
function onOpenDeleteFolders() {
if (folders.value.length > 0) {
const lastItemOfFolder = folders.value[folders.value.length - 1];
const lastItemId = lastItemOfFolder.id;
router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
} else {
router.push({ name: 'home' });
fetchConversations();
}
}
function resetAndFetchData() {
appliedFilter.value = [];
resetBulkActions();
store.dispatch('conversationPage/reset');
store.dispatch('emptyAllConversations');
store.dispatch('clearConversationFilters');
if (hasActiveFolders.value) {
if (hasActiveFolders.value && activeFolder.value?.query) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
}
@@ -599,7 +355,7 @@ function loadMoreConversations() {
if (!hasAppliedFiltersOrActiveFolders.value) {
fetchConversations();
} else if (hasActiveFolders.value) {
} else if (hasActiveFolders.value && activeFolder.value?.query) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
} else if (hasAppliedFilters.value) {
@@ -607,17 +363,6 @@ function loadMoreConversations() {
}
}
// Add a method to handle scroll events
function handleScroll() {
const scroller = conversationDynamicScroller.value;
if (scroller && scroller.hasScrollbar) {
const { scrollTop, scrollHeight, clientHeight } = scroller.$el;
if (scrollHeight - (scrollTop + clientHeight) < 100) {
loadMoreConversations();
}
}
}
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -638,174 +383,10 @@ function onBasicFilterChange(value, type) {
resetAndFetchData();
}
function openLastSavedItemInFolder() {
const lastItemOfFolder = folders.value[folders.value.length - 1];
const lastItemId = lastItemOfFolder.id;
router.push({
name: 'folder_conversations',
params: { id: lastItemId },
});
}
function openLastItemAfterDeleteInFolder() {
if (folders.value.length > 0) {
openLastSavedItemInFolder();
} else {
router.push({ name: 'home' });
fetchConversations();
}
}
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) {
store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
newValue: priority,
from: 'Context menu',
});
useAlert(
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
})
);
});
}
async function markAsUnread(conversationId) {
try {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
redirectToConversationList();
} catch (error) {
// Ignore error
}
}
async function markAsRead(conversationId) {
try {
await store.dispatch('markMessagesRead', {
id: conversationId,
});
} catch (error) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
conversationId,
teamId: team.id,
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
team: team.name,
conversationId,
})
);
} catch (error) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
}
}
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveConversation(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = getConversationById.value(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Pass conversation context through the modal's API
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = getConversationById.value(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
function allSelectedConversationsStatus(status) {
if (!selectedConversations.value.length) return false;
return selectedConversations.value.every(item => {
return getConversationById.value(item)?.status === status;
return store.getters.getConversationById(item)?.status === status;
});
}
@@ -822,8 +403,6 @@ useEmitter('fetch_conversation_stats', () => {
store.dispatch('conversationStats/get', conversationFilters.value);
});
useEventListener(conversationDynamicScroller, 'scroll', handleScroll);
onMounted(() => {
store.dispatch('setChatListFilters', conversationFilters.value);
setFiltersFromUISettings();
@@ -835,46 +414,19 @@ 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('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
provide('assignTeam', onAssignTeam);
provide('assignLabels', onAssignLabels);
provide('removeLabels', onRemoveLabels);
provide('updateConversationStatus', handleResolveConversation);
provide('toggleContextMenu', onContextMenuToggle);
provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
watch(
computed(() => props.conversationInbox),
() => resetAndFetchData()
);
watch(
computed(() => props.teamId),
() => resetAndFetchData()
);
watch(
computed(() => props.label),
() => resetAndFetchData()
@@ -883,6 +435,10 @@ watch(
computed(() => props.conversationType),
() => resetAndFetchData()
);
watch(
computed(() => props.foldersId),
() => resetAndFetchData()
);
watch(activeFolder, (newVal, oldVal) => {
if (newVal !== oldVal) {
@@ -904,7 +460,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -919,32 +475,25 @@ watch(conversationFilters, (newVal, oldVal) => {
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
:is-filter-modal-open="isFilterModalOpen"
@filters-modal="chatListFiltersRef?.onToggleAdvanceFiltersModal"
@basic-filter-change="onBasicFilterChange"
/>
<TeleportWithDirection
v-if="showAddFoldersModal"
to="#saveFilterTeleportTarget"
>
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</TeleportWithDirection>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
v-model:show="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
<ChatListFilters
ref="chatListFiltersRef"
:conversation-inbox="conversationInbox"
:team-id="teamId"
:label="label"
:folders-id="foldersId"
:active-status="activeStatus"
:active-assignee-tab="activeAssigneeTab"
:current-user-details="{ id: currentUser.id, name: currentUser.name }"
:has-active-folders="hasActiveFolders"
:has-applied-filters="hasAppliedFilters"
@apply-filter="onApplyFilter"
@open-add-folders="onOpenAddFolders"
@open-delete-folders="onOpenDeleteFolders"
/>
<ChatTypeTabs
@@ -954,114 +503,45 @@ watch(conversationFilters, (newVal, oldVal) => {
is-compact
@chat-tab-change="updateAssigneeTab"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex overflow-auto justify-center items-center p-4"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationActiveFiltersPreview
:active-folder="activeFolder"
:has-active-folders="hasActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@clear-filters="resetAndFetchData"
@open-filter="chatListFiltersRef?.onToggleAdvanceFiltersModal"
@save-folder="chatListFiltersRef?.onClickOpenAddFoldersModal"
@delete-folder="chatListFiltersRef?.onClickOpenDeleteFoldersModal"
/>
<ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
@select-all-conversations="toggleSelectAll"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="overflow-hidden flex-1 conversations-list hover:overflow-y-auto"
:class="{ 'overflow-hidden': isContextMenuOpen }"
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
>
<DynamicScroller
ref="conversationDynamicScroller"
:items="conversationList"
:min-item-size="24"
class="overflow-auto w-full h-full"
>
<template #default="{ item, index, active }">
<!--
If we encounter resizing issues, we can set the `watchData` prop to true
this will deeply watch the entire object instead of just size dependencies
But it can impact performance
-->
<DynamicScrollerItem
:item="item"
:active="active"
:data-index="index"
:size-dependencies="[
item.messages,
item.labels,
item.uuid,
item.inbox_id,
]"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</DynamicScrollerItem>
</template>
<template #after>
<div v-if="chatListLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-n-slate-11"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-else
:options="intersectionObserverOptions"
@observed="loadMoreConversations"
/>
</template>
</DynamicScroller>
</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
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
>
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationList
:items="conversationList"
:is-loading="chatListLoading"
:show-end-of-list-message="showEndOfListMessage"
:is-context-menu-open="isContextMenuOpen"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:is-expanded-layout="isOnExpandedLayout"
@load-more="loadMoreConversations"
/>
</div>
</template>
@@ -0,0 +1,309 @@
<script setup>
import { ref, unref, computed } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import {
useMapGetter,
useFunctionGetter,
} from 'dashboard/composables/store.js';
import {
useCamelCase,
useSnakeCase,
} from 'dashboard/composables/useTransformKeys';
import { useFilter } from 'shared/composables/useFilter';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import advancedFilterOptions from './widgets/conversation/advancedFilterItems';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
const props = defineProps({
conversationInbox: { type: [String, Number], default: 0 },
teamId: { type: [String, Number], default: 0 },
label: { type: String, default: '' },
foldersId: { type: [String, Number], default: 0 },
activeStatus: { type: String, required: true },
activeAssigneeTab: { type: String, required: true },
currentUserDetails: { type: Object, required: true },
hasActiveFolders: { type: Boolean, default: false },
hasAppliedFilters: { type: Boolean, default: false },
});
const emit = defineEmits([
'applyFilter',
'openAddFolders',
'openDeleteFolders',
]);
const store = useStore();
const { t } = useI18n();
const showAdvancedFilters = ref(false);
const showAddFoldersModal = ref(false);
const showDeleteFoldersModal = ref(false);
const appliedFilter = ref([]);
const foldersQuery = ref({});
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
...filter,
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const folders = useMapGetter('customViews/getConversationCustomViews');
const agentList = useMapGetter('agents/getAgents');
const teamsList = useMapGetter('teams/getTeams');
const inboxesList = useMapGetter('inboxes/getInboxes');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const labels = useMapGetter('labels/getLabels');
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const activeInbox = useMapGetter('getSelectedInbox');
const getTeamFn = useMapGetter('teams/getTeam');
const inbox = useFunctionGetter('inboxes/getInbox', activeInbox);
const conversationCustomAttributes = useFunctionGetter(
'attributes/getAttributesByModel',
'conversation_attribute'
);
const activeFolder = computed(() => {
if (props.foldersId) {
const activeView = folders.value.filter(
view => view.id === Number(props.foldersId)
);
const [firstValue] = activeView;
return firstValue;
}
return undefined;
});
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
const activeTeam = computed(() => {
if (props.teamId) {
return getTeamFn.value(props.teamId);
}
return {};
});
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
function closeAdvanceFiltersModal() {
showAdvancedFilters.value = false;
appliedFilter.value = [];
}
function onApplyFilter(payload) {
const transformedPayload = useSnakeCase(payload);
foldersQuery.value = filterQueryGenerator(transformedPayload);
emit('applyFilter', transformedPayload);
showAdvancedFilters.value = false;
}
function onUpdateSavedFilter(payload, folderName) {
const transformedPayload = useSnakeCase(payload);
const payloadData = {
...unref(activeFolder),
name: unref(folderName),
query: filterQueryGenerator(transformedPayload),
};
store.dispatch('customViews/update', payloadData);
closeAdvanceFiltersModal();
}
function onClickOpenAddFoldersModal() {
showAddFoldersModal.value = true;
}
function onCloseAddFoldersModal() {
showAddFoldersModal.value = false;
}
function onClickOpenDeleteFoldersModal() {
showDeleteFoldersModal.value = true;
}
function onCloseDeleteFoldersModal() {
showDeleteFoldersModal.value = false;
}
function openLastSavedItemInFolder() {
emit('openAddFolders');
}
function openLastItemAfterDeleteInFolder() {
emit('openDeleteFolders');
}
function setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
return {
agents: agentList.value,
teams: teamsList.value,
inboxes: inboxesList.value,
labels: labels.value,
campaigns: campaigns.value,
languages: languages,
countries: countries,
priority: [
{ id: 'low', name: t('CONVERSATION.PRIORITY.OPTIONS.LOW') },
{ id: 'medium', name: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM') },
{ id: 'high', name: t('CONVERSATION.PRIORITY.OPTIONS.HIGH') },
{ id: 'urgent', name: t('CONVERSATION.PRIORITY.OPTIONS.URGENT') },
],
filterTypes: advancedFilterTypes.value,
allCustomAttributes: conversationCustomAttributes.value,
};
}
function initializeExistingFilterToModal() {
const statusFilter = initializeStatusAndAssigneeFilterToModal(
props.activeStatus,
props.currentUserDetails,
props.activeAssigneeTab
);
if (statusFilter) {
appliedFilter.value = [...appliedFilter.value, useCamelCase(statusFilter)];
}
const otherFilters = initializeInboxTeamAndLabelFilterToModal(
props.conversationInbox,
inbox.value,
props.teamId,
activeTeam.value,
props.label
).map(useCamelCase);
appliedFilter.value = [...appliedFilter.value, ...otherFilters];
}
function initializeFolderToFilterModal(newActiveFolder) {
const query = unref(newActiveFolder)?.query?.payload;
if (!Array.isArray(query)) return;
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
const values = Array.isArray(transformed.values)
? generateValuesForEditCustomViews(
useSnakeCase(filter),
setParamsForEditFolderModal()
)
: [];
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values,
};
});
appliedFilter.value = [...appliedFilter.value, ...newFilters];
}
function initalizeAppliedFiltersToModal() {
appliedFilter.value = [...appliedFilters.value];
}
function onToggleAdvanceFiltersModal() {
if (showAdvancedFilters.value === true) {
closeAdvanceFiltersModal();
return;
}
appliedFilter.value = [];
if (!props.hasAppliedFilters && !props.hasActiveFolders) {
initializeExistingFilterToModal();
}
if (props.hasActiveFolders) {
initializeFolderToFilterModal(activeFolder.value);
}
if (props.hasAppliedFilters) {
initalizeAppliedFiltersToModal();
}
showAdvancedFilters.value = true;
}
defineExpose({
onToggleAdvanceFiltersModal,
onClickOpenAddFoldersModal,
onClickOpenDeleteFoldersModal,
onCloseAddFoldersModal,
onCloseDeleteFoldersModal,
foldersQuery,
activeFolder,
appliedFilter,
showAdvancedFilters,
});
</script>
<template>
<div>
<TeleportWithDirection
v-if="showAddFoldersModal"
to="#saveFilterTeleportTarget"
>
<SaveCustomView
v-model="appliedFilter"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
</TeleportWithDirection>
<TeleportWithDirection
v-if="showDeleteFoldersModal"
to="#deleteFilterTeleportTarget"
>
<DeleteCustomViews
v-model:show="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
/>
</TeleportWithDirection>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
>
<ConversationFilter
v-model="appliedFilter"
:folder-name="activeFolderName"
:is-folder-view="hasActiveFolders"
@apply-filter="onApplyFilter"
@update-folder="onUpdateSavedFilter"
@close="closeAdvanceFiltersModal"
/>
</TeleportWithDirection>
</div>
</template>
@@ -16,15 +16,10 @@ const props = defineProps({
isOnExpandedLayout: { type: Boolean, required: true },
conversationStats: { type: Object, required: true },
isListLoading: { type: Boolean, required: true },
isFilterModalOpen: { type: Boolean, default: false },
});
const emit = defineEmits([
'addFolders',
'deleteFolders',
'resetFilters',
'basicFilterChange',
'filtersModal',
]);
const emit = defineEmits(['basicFilterChange', 'filtersModal']);
const { uiSettings, updateUISettings } = useUISettings();
@@ -56,61 +51,33 @@ const toggleConversationLayout = () => {
</script>
<template>
<div
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
<div class="flex items-center justify-center min-w-0">
<h1
class="text-base font-medium truncate text-n-slate-12"
:title="pageTitle"
>
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
{{ pageTitle }}
</h1>
<span
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
class="px-2 py-1 my-0.5 mx-1 rounded-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<template v-if="hasAppliedFilters && !hasActiveFolders">
<div class="relative">
<NextButton
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
icon="i-lucide-save"
slate
xs
faded
@click="emit('addFolders')"
/>
<div
id="saveFilterTeleportTarget"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<NextButton
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
icon="i-lucide-circle-x"
ruby
faded
xs
@click="emit('resetFilters')"
/>
</template>
<div class="flex items-center gap-2">
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<template v-if="hasActiveFolders">
<div class="relative">
<NextButton
@@ -118,8 +85,8 @@ const toggleConversationLayout = () => {
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
icon="i-lucide-pen-line"
slate
xs
faded
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
@click="emit('filtersModal')"
/>
<div
@@ -128,15 +95,6 @@ const toggleConversationLayout = () => {
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<NextButton
id="toggleConversationFilterButton"
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
icon="i-lucide-trash-2"
ruby
xs
faded
@click="emit('deleteFolders')"
/>
</template>
<div v-else class="relative">
<NextButton
@@ -144,8 +102,8 @@ const toggleConversationLayout = () => {
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
icon="i-lucide-list-filter"
slate
xs
faded
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
@click="emit('filtersModal')"
/>
<div
@@ -154,11 +112,6 @@ const toggleConversationLayout = () => {
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<SwitchLayout
:is-on-expanded-layout="isOnExpandedLayout"
@toggle="toggleConversationLayout"
@@ -1,51 +1,170 @@
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
<script setup>
import { inject } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useAlert, useTrack } from 'dashboard/composables';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardV5.vue';
import wootConstants from 'dashboard/constants/globals';
const props = defineProps({
source: {
type: Object,
required: true,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'removeLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
teamId: {
type: [String, Number],
default: 0,
},
};
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
showAssignee: {
type: Boolean,
default: false,
},
isExpandedLayout: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'deleteConversation',
'showResolveAttributesModal',
'redirectToList',
]);
const store = useStore();
const { t } = useI18n();
const selectConversation = inject('selectConversation');
const deSelectConversation = inject('deSelectConversation');
const toggleContextMenu = inject('toggleContextMenu');
const isConversationSelected = inject('isConversationSelected');
const { checkMissingAttributes } = useConversationRequiredAttributes();
const { onAssignAgent, onAssignLabels, onRemoveLabels } = useBulkActions();
async function assignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
conversationId,
teamId: team.id,
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL', {
team: team.name,
conversationId,
})
);
} catch (error) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED'));
}
}
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function updateConversationStatus(conversationId, status, snoozedUntil) {
if (status !== wootConstants.STATUS_TYPE.RESOLVED) {
toggleConversationStatus(conversationId, status, snoozedUntil);
return;
}
// Check for required attributes before resolving
const conversation = store.getters.getConversationById(conversationId);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing, missing } = checkMissingAttributes(
currentCustomAttributes
);
if (hasMissing) {
// Emit event to parent to show modal
emit('showResolveAttributesModal', {
conversationId,
snoozedUntil,
missing,
currentCustomAttributes,
});
} else {
toggleConversationStatus(conversationId, status, snoozedUntil);
}
}
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
store.dispatch('assignPriority', { conversationId, priority }).then(() => {
useTrack(CONVERSATION_EVENTS.CHANGE_PRIORITY, {
newValue: priority,
from: 'Context menu',
});
useAlert(
t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
})
);
});
}
async function markAsUnread(conversationId) {
try {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
emit('redirectToList');
} catch (error) {
// Ignore error
}
}
async function markAsRead(conversationId) {
try {
await store.dispatch('markMessagesRead', {
id: conversationId,
});
} catch (error) {
// Ignore error
}
}
function handleDelete() {
emit('deleteConversation', props.source.id);
}
</script>
<template>
@@ -58,18 +177,19 @@ export default {
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
:is-expanded-layout="isExpandedLayout"
enable-context-menu
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-agent="onAssignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@remove-label="removeLabels"
@assign-label="onAssignLabels"
@remove-label="onRemoveLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@delete-conversation="handleDelete"
/>
</template>
@@ -0,0 +1,265 @@
<script setup>
import { provide, useTemplateRef, shallowRef, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useRouter, useRoute } from 'vue-router';
import { Virtualizer } from 'virtua/vue';
import { useInfiniteScroll, useBreakpoints } from '@vueuse/core';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAlert } from 'dashboard/composables';
import ConversationItem from './ConversationItem.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import wootConstants from 'dashboard/constants/globals';
import {
isOnMentionsView,
isOnUnattendedView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
const props = defineProps({
items: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
showEndOfListMessage: { type: Boolean, default: false },
isContextMenuOpen: { type: Boolean, default: false },
label: { type: String, default: '' },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
conversationType: { type: String, default: '' },
showAssignee: { type: Boolean, default: false },
isExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const parentRef = useTemplateRef('parentRef');
const virtualizerRef = shallowRef(null);
const deleteConversationDialogRef = ref(null);
const conversationToDelete = ref(null);
const resolveAttributesModalRef = ref(null);
provide('contextMenuElementTarget', parentRef);
const breakpoints = useBreakpoints({
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
});
const isLargeScreen = breakpoints.greaterOrEqual('lg');
provide('isLargeScreen', isLargeScreen);
useInfiniteScroll(
parentRef,
() => {
if (props.isLoading || props.showEndOfListMessage) return;
emit('loadMore');
},
{
distance: 400,
}
);
// Keyboard navigation for conversation list
const handleConversationNavigation = direction => {
if (!parentRef.value) return;
// Get all wrapper divs with data-id
const allWrappers = Array.from(parentRef.value.querySelectorAll('[data-id]'));
if (allWrappers.length === 0) return;
const activeIndex = allWrappers.findIndex(wrapper =>
wrapper.querySelector('.active')
);
const delta = direction === 'previous' ? -1 : 1;
const newIndex = activeIndex + delta;
// Clamp index to valid range [0, length-1]
const targetIndex = Math.max(0, Math.min(newIndex, allWrappers.length - 1));
// Click the first child (ConversationItem root element)
allWrappers[targetIndex]?.firstElementChild?.click();
};
useKeyboardEvents({
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
});
function handleDeleteConversation(conversationId) {
conversationToDelete.value = conversationId;
deleteConversationDialogRef.value?.open();
}
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 deleteConversation() {
if (!conversationToDelete.value) return;
try {
await store.dispatch('deleteConversation', conversationToDelete.value);
redirectToConversationList();
deleteConversationDialogRef.value?.close();
conversationToDelete.value = null;
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
function handleShowResolveAttributesModal({
conversationId,
snoozedUntil,
missing,
currentCustomAttributes,
}) {
const conversationContext = {
id: conversationId,
snoozedUntil,
};
resolveAttributesModalRef.value?.open(
missing,
currentCustomAttributes,
conversationContext
);
}
function toggleConversationStatus(
conversationId,
status,
snoozedUntil,
customAttributes = null
) {
const payload = {
conversationId,
status,
snoozedUntil,
};
if (customAttributes) {
payload.customAttributes = customAttributes;
}
store.dispatch('toggleStatus', payload).then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
});
}
function handleResolveWithAttributes({ attributes, context }) {
if (context) {
const existingConversation = store.getters.getConversationById(context.id);
const currentCustomAttributes =
existingConversation?.custom_attributes || {};
const mergedAttributes = { ...currentCustomAttributes, ...attributes };
toggleConversationStatus(
context.id,
wootConstants.STATUS_TYPE.RESOLVED,
context.snoozedUntil,
mergedAttributes
);
}
}
</script>
<template>
<div
ref="parentRef"
class="flex-1 w-full h-full touch-pan-y overflow-x-hidden overscroll-contain [-webkit-overflow-scrolling:touch] [contain:strict] [overflow-anchor:none] [will-change:scroll-position] px-2 pt-2"
:class="isContextMenuOpen ? 'overflow-hidden' : 'overflow-y-auto'"
>
<Virtualizer
ref="virtualizerRef"
:data="items"
:scroll-ref="parentRef"
:item-size="isLargeScreen && isExpandedLayout ? 48 : 76"
:buffer-size="100"
item="div"
class="[&>div]:after:content-[''] [&>div]:after:absolute [&>div]:after:bottom-0 [&>div]:after:left-0 [&>div]:after:right-0 [&>div]:after:h-px [&>div]:after:bg-n-weak [&>div]:after:pointer-events-none [&>div:has(*:hover)]:after:!bg-n-surface-1 [&>div:has(+_*:hover)]:after:!bg-n-surface-1 [&>div:has(.active)]:after:!bg-n-surface-1 [&>div:has(+_*_.active)]:after:!bg-n-surface-1 [&>div:has(.selected)]:after:!bg-n-surface-1 [&>div:has(+_*_.selected)]:after:!bg-n-surface-1"
>
<template #default="{ item }">
<div :key="item.id" :data-id="item.id">
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssignee"
:is-expanded-layout="isExpandedLayout"
@delete-conversation="handleDeleteConversation"
@show-resolve-attributes-modal="handleShowResolveAttributesModal"
@redirect-to-list="redirectToConversationList"
/>
</div>
</template>
</Virtualizer>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: conversationToDelete,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
/>
<ConversationResolveAttributesModal
ref="resolveAttributesModalRef"
@submit="handleResolveWithAttributes"
/>
<div class="py-2">
<div v-if="isLoading" class="flex justify-center my-4">
<Spinner class="text-n-brand" />
</div>
<p
v-else-if="showEndOfListMessage"
class="p-4 text-center text-sm text-n-slate-10 whitespace-nowrap"
>
<Icon
icon="i-woot-party"
class="size-4 inline-block align-middle ltr:mr-2 rtl:ml-2"
/>
<span class="align-middle">
{{ t('CHAT_LIST.EOF') }}
</span>
</p>
</div>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup>
import { computed, inject } from 'vue';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
const props = defineProps({
index: {
@@ -28,11 +29,14 @@ const props = defineProps({
},
});
const { formatCompactWithDecimal } = useNumberFormatter();
const activeIndex = inject('activeIndex');
const updateActiveIndex = inject('updateActiveIndex');
const active = computed(() => props.index === activeIndex.value);
const getItemCount = computed(() => props.count);
const count = computed(() => props.count);
const getItemCount = computed(() => formatCompactWithDecimal(count.value));
const onTabClick = event => {
event.preventDefault();
@@ -59,7 +63,11 @@ const onTabClick = event => {
{{ name }}
<div
v-if="showBadge"
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
v-tooltip.top="{
content: count,
delay: { show: 500, hide: 0 },
}"
class="rounded-full h-5 flex items-center justify-center font-interDisplay text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
:class="[
active
? 'bg-n-blue-3 text-n-blue-11'
@@ -48,13 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
class="w-full px-4 py-0 [&_ul]:p-0 h-10"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm [&_a]:font-medium"
class="text-sm"
:index="index"
:name="item.name"
:count="item.count"
@@ -0,0 +1,151 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useElementSize } from '@vueuse/core';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
activeFolder: { type: Object, default: null },
hasActiveFolders: { type: Boolean, default: false },
isOnExpandedLayout: { type: Boolean, default: false },
});
const emit = defineEmits([
'clearFilters',
'saveFolder',
'deleteFolder',
'openFilter',
]);
const { t } = useI18n();
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const activeFolderQuery = computed(() => {
const query = props.activeFolder?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const activeFilterQueryData = computed(() => {
return props.hasActiveFolders
? activeFolderQuery.value
: appliedFilters.value;
});
const showPreview = computed(() => {
return activeFilterQueryData.value.length > 0;
});
const containerRef = ref(null);
const { width: containerWidth } = useElementSize(containerRef);
const maxVisibleFilters = computed(() => {
const totalFilters = activeFilterQueryData.value.length;
const width = containerWidth.value;
if (!width || totalFilters === 0) return 0;
const chipWidth = 200; // Estimated avg width + gap
const buttonSpace = 400; // Reserved space for "Edit/Delete/More" buttons
// Calculate available space across 2 lines
const totalAvailablePixels = Math.max(0, width * 2 - buttonSpace);
const maxCapacity = Math.floor(totalAvailablePixels / chipWidth);
// Return at least 1, but no more than actual total
return Math.max(1, Math.min(maxCapacity, totalFilters));
});
</script>
<template>
<div
v-if="showPreview"
ref="containerRef"
class="flex items-center justify-between gap-2 mx-2 px-2 pb-2 pt-0.5 border-b-[1.2px] border-dashed border-n-strong mt-px"
>
<ActiveFilterPreview
:applied-filters="activeFilterQueryData"
:max-visible-filters="maxVisibleFilters"
:more-filters-label="
t('FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: activeFilterQueryData.length - maxVisibleFilters,
})
"
:clear-button-label="t('FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')"
:show-clear-button="!hasActiveFolders"
class="flex-1 min-w-0"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
>
<template #actions>
<template v-if="hasActiveFolders">
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Button
:label="t('FILTER.ACTIVE_FILTERS.EDIT_BUTTON')"
link
class="hover:!no-underline !text-n-blue-11 !px-0.5"
xs
@click="emit('openFilter')"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<div class="inline-flex items-center">
<Button
:label="t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
link
ruby
class="hover:!no-underline !no-underline !px-0.5"
xs
@click="emit('deleteFolder')"
/>
<!-- Teleport target for Delete modal -->
<div
id="deleteFilterTeleportTarget"
class="absolute z-50 mt-2"
:class="{ 'ltr:right-0 rtl:left-0': isOnExpandedLayout }"
/>
</div>
</template>
<div
v-if="appliedFilters.length > 0"
class="w-px h-3 bg-n-weak rounded-lg"
/>
<div
v-if="appliedFilters.length > 0"
class="relative inline-flex items-center"
>
<Button
:label="t('FILTER.CUSTOM_VIEWS.SAVE_VIEW')"
link
class="hover:!no-underline !text-n-blue-11 !px-0.5"
xs
@click="emit('saveFolder')"
/>
<!-- Teleport target for Save modal -->
<div
id="saveFilterTeleportTarget"
class="absolute z-50 top-6"
:class="{ 'ltr:left-0 rtl:right-0': isOnExpandedLayout }"
/>
</div>
</template>
</ActiveFilterPreview>
</div>
<template v-else />
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
@@ -26,6 +26,7 @@ const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const filterContainerRef = useTemplateRef('filterContainerRef');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
@@ -131,18 +132,21 @@ const handleSortChange = value => {
</script>
<template>
<div class="relative flex">
<div ref="filterContainerRef" class="relative flex">
<NextButton
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
slate
faded
xs
sm
:variant="showActionsDropdown ? 'faded' : 'ghost'"
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
v-on-click-outside="() => toggleDropdown()"
v-on-click-outside="[
() => toggleDropdown(),
{ ignore: [filterContainerRef] },
]"
class="mt-1 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4 absolute z-40 top-full"
:class="{
'ltr:left-0 rtl:right-0': !isOnExpandedLayout,
@@ -100,6 +100,9 @@ export default {
v-if="currentChat.id"
:chat="currentChat"
:show-back-button="isOnExpandedLayout && !isInboxView"
:class="{
'border-b border-b-n-weak !pt-2': !dashboardApps.length,
}"
/>
<woot-tabs
v-if="dashboardApps.length && currentChat.id"
@@ -243,7 +243,7 @@ const deleteConversation = () => {
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-3': !compact,
'px-2 rounded-lg': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -1,254 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>
@@ -0,0 +1,197 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedAgent = ref(null);
const assignableAgentsUiFlags = useMapGetter(
'inboxAssignableAgents/getUIFlags'
);
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: 'None',
thumbnail: {
name: 'None',
src: '',
},
},
];
assignableAgents.value.forEach(agent => {
items.push({
action: 'select',
value: agent.id,
label: agent.name,
thumbnail: {
name: agent.name,
src: agent.thumbnail,
},
isSelected: selectedAgent.value?.id === agent.id,
});
});
return items;
});
const handleSelectAgent = item => {
if (item.value === 'none') {
selectedAgent.value = { id: null, name: 'None' };
} else {
const agent = assignableAgents.value.find(a => a.id === item.value);
selectedAgent.value = agent || { id: null, name: 'None' };
}
};
const handleAssign = () => {
emit('select', selectedAgent.value);
selectedAgent.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedAgent.value = null;
};
const handleDismiss = () => {
selectedAgent.value = null;
toggleDropdown(false);
};
const handleToggleDropdown = () => {
const willOpen = !showDropdown.value;
toggleDropdown();
// Fetch agents only when opening the dropdown
if (willOpen && props.selectedInboxes.length > 0) {
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-check"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="handleToggleDropdown"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="agentMenuItems"
:is-loading="isLoading"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80 overflow-y-auto"
@action="handleSelectAgent"
>
<template #footer>
<div
v-if="selectedAgent"
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedAgent.id"
keypath="BULK_ACTION.ASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #conversationCount>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #agentName>
<strong class="text-n-slate-12">
{{ selectedAgent.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,153 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
type: {
type: String,
default: 'conversation',
},
isLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const buttonLabel = computed(() => {
if (props.type === 'contact') return t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS');
return '';
});
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
color: label.color,
id: label.id,
isSelected: isLabelSelected(label.title),
}));
});
const toggleLabelSelection = labelTitle => {
const index = selectedLabels.value.indexOf(labelTitle);
if (index > -1) {
selectedLabels.value.splice(index, 1);
} else {
selectedLabels.value.push(labelTitle);
}
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
};
const handleDismiss = () => {
selectedLabels.value = [];
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
:class="{
'bg-n-alpha-2': showDropdown,
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit':
isTypeContact,
}"
:disabled="disabled"
:is-loading="isLoading"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="labelMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="bottom-8 w-60 max-h-80 overflow-y-auto"
:class="{
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0':
!isTypeContact,
'ltr:right-0 rtl:left-0 mb-1': isTypeContact,
}"
@action="item => toggleLabelSelection(item.value)"
>
<template #thumbnail="{ item }">
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="isLabelSelected(item.value)"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
class="sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,164 @@
<script setup>
import { useTemplateRef, computed, ref, onMounted } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
const teams = useMapGetter('teams/getTeams');
const teamMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.TEAMS.NONE'),
},
];
teams.value.forEach(team => {
items.push({
action: 'select',
value: team.id,
label: team.name,
isSelected: selectedTeam.value?.id === team.id,
});
});
return items;
});
const handleSelectTeam = item => {
if (item.value === 'none') {
selectedTeam.value = { id: 0, name: 'None' };
} else {
const foundTeam = teams.value.find(team => team.id === item.value);
selectedTeam.value = foundTeam || { id: 0, name: 'None' };
}
};
const handleAssign = () => {
emit('select', selectedTeam.value);
selectedTeam.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedTeam.value = null;
};
onMounted(() => {
store.dispatch('teams/get');
});
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="teamMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80 overflow-y-auto"
@action="handleSelectTeam"
>
<template #footer>
<div
v-if="selectedTeam"
class="pt-2 pb-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px] after:absolute after:-bottom-2 after:left-0 after:w-full after:h-2 after:bg-n-alpha-3 after:backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedTeam.id"
keypath="BULK_ACTION.TEAMS.ASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #conversationCount>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #teamName>
<strong class="text-n-slate-12">
{{ selectedTeam.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.TEAMS.UNASSIGN_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const updateMenuItems = computed(() => {
const items = [];
if (props.showResolve) {
items.push({
action: 'update',
value: 'resolved',
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
icon: 'i-lucide-check',
});
}
if (props.showReopen) {
items.push({
action: 'update',
value: 'open',
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
icon: 'i-lucide-redo',
});
}
if (props.showSnooze) {
items.push({
action: 'update',
value: 'snoozed',
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
icon: 'i-lucide-alarm-clock',
});
}
return items;
});
const handleUpdate = item => {
if (item.value === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', item.value);
}
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-circle-fading-arrow-up"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="updateMenuItems"
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
@action="handleUpdate"
/>
</Transition>
</div>
</template>
@@ -1,7 +1,9 @@
<script>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -10,313 +12,172 @@ import {
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import BulkAgentActions from './BulkAgentActions.vue';
import BulkUpdateActions from './BulkUpdateActions.vue';
import BulkLabelActions from './BulkLabelActions.vue';
import BulkTeamActions from './BulkTeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
const props = defineProps({
conversations: {
type: Array,
default: () => [],
},
props: {
conversations: {
type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
allConversationsSelected: {
type: Boolean,
default: false,
},
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
selectedInboxes: {
type: Array,
default: () => [],
},
mounted() {
emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showOpenAction: {
type: Boolean,
default: false,
},
unmounted() {
emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
showResolvedAction: {
type: Boolean,
default: false,
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
showSnoozedAction: {
type: Boolean,
default: false,
},
};
});
const emit = defineEmits(['selectAllConversations']);
const {
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomTimeSnoozeModal.value = true;
} else {
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
}
function onCmdReopenConversation() {
onUpdateConversations('open', null);
}
function onCmdResolveConversation() {
onUpdateConversations('resolved', null);
}
function customSnoozeTime(customSnoozedTime) {
showCustomTimeSnoozeModal.value = false;
if (customSnoozedTime) {
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
}
}
function hideCustomSnoozeModal() {
showCustomTimeSnoozeModal.value = false;
}
// Computed property with getter/setter to enable v-model usage
const allSelected = computed({
get: () => props.allConversationsSelected,
set: value => {
emit('selectAllConversations', value);
},
});
onMounted(() => {
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
onUnmounted(() => {
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
</script>
<template>
<div class="bulk-action__container">
<div class="flex items-center justify-between">
<label class="flex items-center justify-between bulk-action__panel">
<input
type="checkbox"
class="checkbox"
:checked="allConversationsSelected"
:indeterminate.prop="!allConversationsSelected"
@change="selectAll($event)"
/>
<span>
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="flex items-center gap-1 bulk-action__actions">
<NextButton
v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
icon="i-lucide-tags"
slate
xs
faded
@click="toggleLabelActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-repeat"
slate
xs
faded
@click="toggleUpdateActions"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-plus"
slate
xs
faded
@click="toggleAgentList"
/>
<NextButton
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
faded
@click="toggleTeamsList"
/>
</div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div>
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
<Transition
enter-active-class="transition-all duration-200 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-2"
>
<div
v-show="conversations.length > 0"
class="px-2 absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
<div
v-if="allConversationsSelected"
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text"
>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<div
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]"
>
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1">
<label class="cursor-pointer flex items-center gap-1.5">
<Checkbox
v-model="allSelected"
:indeterminate="!allConversationsSelected"
/>
<span class="cursor-pointer">
{{
$t('BULK_ACTION.CONVERSATIONS_SELECTED', {
conversationCount: conversations.length,
})
}}
</span>
</label>
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" />
<NextButton
:label="$t('BULK_ACTION.CLEAR_SELECTION')"
ghost
class="!text-n-blue-11 !px-1 !h-6"
sm
@click="allSelected = false"
/>
</div>
<div class="flex items-center gap-2">
<BulkLabelActions @assign="onAssignLabels" />
<BulkUpdateActions
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="onUpdateConversations"
/>
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="onAssignAgent"
/>
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</div>
</div>
<woot-modal
v-model:show="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</Transition>
</template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>
@@ -1,141 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const emit = defineEmits(['close', 'assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const query = ref('');
const selectedLabels = ref([]);
const filteredLabels = computed(() => {
if (!query.value) return labels.value;
return labels.value.filter(label =>
label.title.toLowerCase().includes(query.value.toLowerCase())
);
});
const hasLabels = computed(() => labels.value.length > 0);
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
const isLabelSelected = label => {
return selectedLabels.value.includes(label);
};
const onClose = () => {
emit('close');
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
}
};
</script>
<template>
<div
v-on-click-outside="onClose"
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
role="dialog"
aria-labelledby="label-dialog-title"
>
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between p-2.5">
<span class="text-sm font-medium">{{
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
}}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="flex flex-col max-h-60 min-h-0">
<header class="py-2 px-2.5">
<Input
v-model="query"
type="search"
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
icon-left="i-lucide-search"
size="sm"
class="w-full"
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
/>
</header>
<ul
v-if="hasLabels"
class="flex-1 overflow-y-auto m-0 list-none"
role="listbox"
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
>
<li v-if="!hasFilteredLabels" class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
}}</span>
</li>
<li
v-for="label in filteredLabels"
:key="label.id"
class="my-1 mx-0 py-0 px-2.5"
role="option"
:aria-selected="isLabelSelected(label.title)"
>
<label
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
>
<input
v-model="selectedLabels"
type="checkbox"
:value="label.title"
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
:aria-label="label.title"
/>
<span
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
>
{{ label.title }}
</span>
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: label.color }"
/>
</label>
</li>
</ul>
<div v-else class="p-2 text-center">
<span class="text-sm text-n-slate-11">{{
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
}}</span>
</div>
<footer class="p-2">
<NextButton
sm
type="submit"
class="w-full"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</footer>
</div>
</div>
</template>
<style scoped lang="scss">
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
</style>
@@ -1,145 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
},
emits: ['assignTeam', 'close'],
data() {
return {
query: '',
selectedteams: [],
};
},
computed: {
...mapGetters({ teams: 'teams/getTeams' }),
filteredTeams() {
return [
{ name: 'None', id: 0 },
...this.teams.filter(team =>
team.name.toLowerCase().includes(this.query.toLowerCase())
),
];
},
},
methods: {
assignTeam(key) {
this.$emit('assignTeam', key);
},
onClose() {
this.$emit('close');
},
},
};
</script>
<template>
<div v-on-clickaway="onClose" class="bulk-action__teams">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.TEAMS.TEAM_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div class="team__list-container">
<ul>
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<template v-if="filteredTeams.length">
<li v-for="team in filteredTeams" :key="team.id">
<div class="team__list-item" @click="assignTeam(team)">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ team.name }}
</span>
</div>
</li>
</template>
<li v-else>
<div class="team__list-item">
<span class="my-0 ltr:ml-2 rtl:mr-2 text-n-slate-12">
{{ $t('BULK_ACTION.TEAMS.NO_TEAMS_AVAILABLE') }}
</span>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__teams {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.team__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent w-full h-[unset];
}
}
}
.triangle {
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.team__list-item {
@apply flex items-center p-2.5 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
</style>
@@ -1,109 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update', 'close']);
const { t } = useI18n();
const actions = ref([
{ icon: 'i-lucide-check', key: 'resolved' },
{ icon: 'i-lucide-redo', key: 'open' },
{ icon: 'i-lucide-alarm-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', key);
}
};
const onClose = () => {
emit('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div
v-on-clickaway="onClose"
class="absolute z-20 w-auto origin-top-right border border-solid rounded-lg shadow-md ltr:right-2 rtl:left-2 top-12 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak"
/>
</svg>
</div>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<Button ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="px-2.5 pt-0 pb-2.5">
<WootDropdownMenu class="m-0 list-none">
<template v-for="action in actions">
<WootDropdownItem v-if="showAction(action.key)" :key="action.key">
<Button
ghost
sm
slate
class="!w-full !justify-start"
:icon="action.icon"
:label="actionLabel(action.key)"
@click="updateConversations(action.key)"
/>
</WootDropdownItem>
</template>
</WootDropdownMenu>
</div>
</div>
</template>
@@ -23,9 +23,15 @@ export function useBulkActions() {
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
// Only remove one instance of the inboxId, not all
// This handles the case where multiple conversations from the same inbox are selected
const index = selectedInboxes.value.indexOf(inboxId);
if (index > -1) {
selectedInboxes.value = [
...selectedInboxes.value.slice(0, index),
...selectedInboxes.value.slice(index + 1),
];
}
}
function resetBulkActions() {
@@ -39,6 +39,7 @@ export default {
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
SMALL_SCREEN_BREAKPOINT: 768,
LARGE_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
@@ -75,6 +75,11 @@
"ADDITIONAL_FILTERS": "Additional filters",
"CUSTOM_ATTRIBUTES": "Custom attributes"
},
"ACTIVE_FILTERS": {
"MORE_FILTERS": "+ {count} more filters",
"CLEAR_FILTERS": "Clear filters",
"EDIT_BUTTON": "Edit"
},
"CUSTOM_VIEWS": {
"ADD": {
"TITLE": "Do you want to save this filter?",
@@ -95,8 +100,9 @@
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"SAVE_VIEW": "Save View",
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"DELETE_BUTTON": "Delete",
"MODAL": {
"CONFIRM": {
"TITLE": "Confirm deletion",
@@ -1,12 +1,14 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
"AGENT_SELECT_LABEL": "Select agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"CLEAR_SELECTION": "Clear",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {agentName}? | Are you sure to assign {n} conversations to {agentName}?",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"YES": "Yes",
"CANCEL": "Cancel",
"SEARCH_INPUT_PLACEHOLDER": "Search",
"ASSIGN_AGENT_TOOLTIP": "Assign agent",
"ASSIGN_TEAM_TOOLTIP": "Assign team",
@@ -38,6 +40,8 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {n} conversation to {teamName}? | Are you sure to assign {n} conversations to {teamName}?",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {n} conversation? | Are you sure to unassign {n} conversations?",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
@@ -2,7 +2,7 @@
"CHAT_LIST": {
"LOADING": "Fetching conversations",
"LOAD_MORE_CONVERSATIONS": "Load more conversations",
"EOF": "All conversations loaded 🎉",
"EOF": "All conversations loaded",
"LIST": {
"404": "There are no active conversations in this group."
},
@@ -458,6 +458,9 @@
"INSTAGRAM": {
"PLACEHOLDER": "Add Instagram"
},
"TELEGRAM": {
"PLACEHOLDER": "Add Telegram"
},
"TIKTOK": {
"PLACEHOLDER": "Add TikTok"
},
@@ -71,7 +71,8 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels"
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -890,6 +890,20 @@
"CONFIRM": "Create new template",
"CANCEL": "Go back"
},
"UTILITY_ANALYZER": {
"ACTION": "Check utility fit",
"HELPER_NOTE": "Check this message before submission to improve Utility fit. The system creates a dedicated CSAT template with buttons for reporting and submits it as Utility; Meta may still reclassify it as Marketing based on content.",
"RESULT_LABEL": "Meta category prediction",
"GUIDANCE_NOTE": "This is a guidance check, not a guarantee of Meta approval.",
"SUGGESTION_LABEL": "Suggested utility-safe rewrite",
"APPLY": "Use this rewrite",
"ERROR_MESSAGE": "Couldn't analyze the message. Please try again.",
"CLASSIFICATION": {
"LIKELY_UTILITY": "Likely Utility",
"LIKELY_MARKETING": "Likely Marketing",
"UNCLEAR": "Needs clarification"
}
},
"SURVEY_RULE": {
"LABEL": "Survey rule",
"DESCRIPTION_PREFIX": "Send the survey if the conversation",
@@ -901,7 +915,7 @@
"SELECT_PLACEHOLDER": "select labels"
},
"NOTE": "Note: CSAT surveys are sent only once per conversation",
"WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.",
"WHATSAPP_NOTE": "Note: When you save, the system creates a dedicated CSAT template in WhatsApp (used to capture rating and feedback in reports) and submits it as Utility for approval. Meta may still classify it as Marketing based on content. After approval, surveys are sent only once per conversation as per the survey rule.",
"API": {
"SUCCESS_MESSAGE": "CSAT settings updated successfully",
"ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later."
@@ -1,11 +1,10 @@
<script setup>
import { computed, ref } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
@@ -34,7 +33,6 @@ const { t } = useI18n();
const selectedCount = computed(() => props.selectedContactIds.length);
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
const showLabelSelector = ref(false);
const selectAllLabel = computed(() => {
if (!totalVisibleContacts.value) {
@@ -73,22 +71,11 @@ const selectionModel = computed({
});
const emitClearSelection = () => {
showLabelSelector.value = false;
emit('clearSelection');
};
const toggleLabelSelector = () => {
if (!selectedCount.value || props.isLoading) return;
showLabelSelector.value = !showLabelSelector.value;
};
const closeLabelSelector = () => {
showLabelSelector.value = false;
};
const handleAssignLabels = labels => {
emit('assignLabels', labels);
closeLabelSelector();
};
</script>
@@ -107,56 +94,33 @@ const handleAssignLabels = labels => {
<Button
sm
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1.5"
class="!px-1"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<div
v-on-click-outside="closeLabelSelector"
class="relative flex items-center"
>
<Button
sm
faded
slate
icon="i-lucide-tags"
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
@click="toggleLabelSelector"
/>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<LabelActions
v-if="showLabelSelector"
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
@assign="handleAssignLabels"
/>
</transition>
</div>
<BulkLabelActions
type="contact"
:is-loading="isLoading"
:disabled="!selectedCount"
class="[&>button]:!text-n-blue-11 [&>button>span]:!text-n-blue-11 [&>button]:!px-2"
@assign="handleAssignLabels"
/>
<div class="w-px h-3 bg-n-weak rounded-lg" />
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
sm
faded
ghost
ruby
icon="i-lucide-trash"
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
:disabled="!selectedCount || isLoading"
:is-loading="isLoading"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
class="!px-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
@click="emit('deleteSelected')"
/>
</Policy>
@@ -1,9 +1,12 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { computed, watch, onMounted, onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { onBeforeRouteLeave } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
import ChatList from 'dashboard/components/ChatList.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import CmdBarConversationSnooze from 'dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue';
@@ -11,187 +14,144 @@ import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
export default {
components: {
ChatList,
ConversationBox,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
const props = defineProps({
inboxId: {
type: [String, Number],
default: 0,
},
beforeRouteLeave(to, from, next) {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
conversationId: {
type: [String, Number],
default: 0,
},
props: {
inboxId: {
type: [String, Number],
default: 0,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
});
return {
uiSettings,
updateUISettings,
accountId,
};
},
data() {
return {
showSearchModal: false,
};
},
computed: {
...mapGetters({
chatList: 'getAllConversations',
currentChat: 'getSelectedChat',
}),
showConversationList() {
return this.isOnExpandedLayout ? !this.conversationId : true;
},
showMessageView() {
return this.conversationId ? true : !this.isOnExpandedLayout;
},
isOnExpandedLayout() {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
this.uiSettings;
return conversationDisplayType !== CONDENSED;
},
const route = useRoute();
const store = useStore();
const { uiSettings } = useUISettings();
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
const chatList = useMapGetter('getAllConversations');
const currentChat = useMapGetter('getSelectedChat');
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
},
watch: {
conversationId() {
this.fetchConversationIfUnavailable();
},
},
const isOnExpandedLayout = computed(() => {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
uiSettings.value;
return conversationDisplayType !== CONDENSED;
});
created() {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!this.conversationId) {
this.$store.dispatch('clearSelectedState');
}
},
const showConversationList = computed(() =>
isOnExpandedLayout.value ? !props.conversationId : true
);
mounted() {
this.$store.dispatch('agents/get');
this.$store.dispatch('portals/index');
this.initialize();
this.$watch('$store.state.route', () => this.initialize());
this.$watch('chatList.length', () => {
this.setActiveChat();
});
},
const showMessageView = computed(() =>
props.conversationId ? true : !isOnExpandedLayout.value
);
methods: {
onConversationLoad() {
this.fetchConversationIfUnavailable();
},
initialize() {
this.$store.dispatch('setActiveInbox', this.inboxId);
this.setActiveChat();
},
toggleConversationLayout() {
const { LAYOUT_TYPES } = wootConstants;
const {
conversation_display_type:
conversationDisplayType = LAYOUT_TYPES.CONDENSED,
} = this.uiSettings;
const newViewType =
conversationDisplayType === LAYOUT_TYPES.CONDENSED
? LAYOUT_TYPES.EXPANDED
: LAYOUT_TYPES.CONDENSED;
this.updateUISettings({
conversation_display_type: newViewType,
previously_used_conversation_display_type: newViewType,
});
},
fetchConversationIfUnavailable() {
if (!this.conversationId) {
return;
}
const chat = this.findConversation();
if (!chat) {
this.$store.dispatch('getConversation', this.conversationId);
}
},
findConversation() {
const conversationId = parseInt(this.conversationId, 10);
const [chat] = this.chatList.filter(c => c.id === conversationId);
return chat;
},
setActiveChat() {
if (this.conversationId) {
const selectedConversation = this.findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === this.currentChat.id
) {
return;
}
const { messageId } = this.$route.query;
this.$store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
}
},
onSearch() {
this.showSearchModal = true;
},
closeSearch() {
this.showSearchModal = false;
},
},
const shouldShowSidebar = computed(() => {
if (!currentChat.value.id) {
return false;
}
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
return isContactSidebarOpen;
});
const findConversation = () => {
const conversationId = parseInt(props.conversationId, 10);
const [chat] = chatList.value.filter(c => c.id === conversationId);
return chat;
};
const fetchConversationIfUnavailable = () => {
if (!props.conversationId) {
return;
}
const chat = findConversation();
if (!chat) {
store.dispatch('getConversation', props.conversationId);
}
};
const setActiveChat = () => {
if (props.conversationId) {
const selectedConversation = findConversation();
// If conversation doesn't exist or selected conversation is same as the active
// conversation, don't set active conversation.
if (
!selectedConversation ||
selectedConversation.id === currentChat.value.id
) {
return;
}
const { messageId } = route.query;
store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
store.dispatch('clearSelectedState');
}
};
const initialize = () => {
store.dispatch('setActiveInbox', props.inboxId);
setActiveChat();
};
const onConversationLoad = () => {
fetchConversationIfUnavailable();
};
onBeforeRouteLeave((to, from, next) => {
// Clear selected state if navigating away from a conversation to a route without a conversationId to prevent stale data issues
// and resolves timing issues during navigation with conversation view and other screens
if (props.conversationId) {
store.dispatch('clearSelectedState');
}
next(); // Continue with navigation
});
watch(() => props.conversationId, fetchConversationIfUnavailable);
watch(() => store.state.route, initialize);
watch(() => chatList.value.length, setActiveChat);
onBeforeMount(() => {
// Clear selected state early if no conversation is selected
// This prevents child components from accessing stale data
// and resolves timing issues during navigation
// with conversation view and other screens
if (!props.conversationId) {
store.dispatch('clearSelectedState');
}
});
onMounted(() => {
store.dispatch('agents/get');
store.dispatch('portals/index');
initialize();
});
</script>
<template>
@@ -58,12 +58,14 @@ export default {
twitter: '',
linkedin: '',
github: '',
telegram: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
{ key: 'github', prefixURL: 'https://github.com/' },
{ key: 'telegram', prefixURL: 'https://t.me/' },
{ key: 'tiktok', prefixURL: 'https://tiktok.com/@' },
],
};
@@ -175,12 +177,14 @@ export default {
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
social_telegram_user_name: telegramUserName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
github: socialProfiles.github || '',
telegram: socialProfiles.telegram || telegramUserName || '',
instagram: socialProfiles.instagram || '',
tiktok: socialProfiles.tiktok || '',
};
@@ -81,10 +81,14 @@ export default {
screen_name: twitterScreenName,
social_telegram_user_name: telegramUsername,
} = this.additionalAttributes;
const telegram = socialProfiles?.telegram || telegramUsername || '';
const twitter = socialProfiles?.twitter || twitterScreenName || '';
return {
twitter: twitterScreenName,
telegram: telegramUsername,
...(socialProfiles || {}),
twitter,
telegram,
};
},
// Delete Modal
@@ -23,15 +23,15 @@ export default {
<template>
<NextButton
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
:icon="
isOnExpandedLayout
? 'i-lucide-arrow-left-to-line'
: 'i-lucide-arrow-right-to-line'
"
icon="i-woot-expand-list"
slate
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
sm
ghost
class="flex-shrink-0 md:inline-flex hidden"
:class="{
'ltr:!rotate-180 rtl:!rotate-0': isOnExpandedLayout,
'ltr:!rotate-0 rtl:!rotate-180': !isOnExpandedLayout,
}"
@click="toggle"
/>
</template>
@@ -182,6 +182,7 @@ onMounted(() => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[bot.id]"
@click="openDeletePopup(bot)"
/>
@@ -268,6 +268,7 @@ const confirmDeletion = () => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[agent.id]"
@click="openDeletePopup(agent, index)"
/>
@@ -70,14 +70,6 @@ const automationActive = computed({
:is-loading="loading"
@click="$emit('edit', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-woot-bin"
slate
sm
@click="$emit('delete', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.CLONE.TOOLTIP')"
icon="i-woot-clone"
@@ -86,6 +78,15 @@ const automationActive = computed({
:is-loading="loading"
@click="$emit('clone', automation)"
/>
<Button
v-tooltip.top="$t('AUTOMATION.FORM.DELETE')"
:is-loading="loading"
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="$emit('delete', automation)"
/>
</div>
</BaseTableCell>
</template>
@@ -232,6 +232,7 @@ const tableHeaders = computed(() => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[cannedItem.id]"
@click="openDeletePopup(cannedItem)"
/>
@@ -66,6 +66,7 @@ const getFormattedPermissions = role => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[customRole.id]"
@click="emit('delete', customRole)"
/>
@@ -116,17 +116,15 @@ onMounted(() => {
</script>
<template>
<div class="w-full p-8 overflow-auto">
<div
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3"
>
<ChannelItem
v-for="channel in channelList"
:key="channel.key"
:channel="channel"
:enabled-features="enabledFeatures"
@channel-item-click="initChannelAuth"
/>
</div>
<div
class="grid max-w-3xl grid-cols-1 xs:grid-cols-2 mx-0 gap-6 sm:grid-cols-3 p-8"
>
<ChannelItem
v-for="channel in channelList"
:key="channel.key"
:channel="channel"
:enabled-features="enabledFeatures"
@channel-item-click="initChannelAuth"
/>
</div>
</template>
@@ -115,7 +115,7 @@ export default {
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.IMAP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
@@ -124,7 +124,7 @@ export default {
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.IMAP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
@@ -132,7 +132,7 @@ export default {
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
@@ -140,7 +140,7 @@ export default {
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.IMAP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.PASSWORD.PLACE_HOLDER')"
type="password"
@@ -62,14 +62,14 @@ const items = computed(() => {
<div class="mx-auto flex flex-col gap-6 mb-8 max-w-7xl w-full !px-6">
<PageHeader class="block lg:hidden !mb-0" :header-title="pageTitle" />
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[52rem]"
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh]"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
:global-config="globalConfig"
:items="items"
/>
<div class="col-span-6 overflow-hidden">
<div class="col-span-6 flex flex-col overflow-y-auto">
<router-view />
</div>
</div>
@@ -170,6 +170,7 @@ const openDelete = inbox => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="openDelete(inbox)"
/>
</div>
@@ -214,6 +214,18 @@ export default {
const { medium, channel_type: type } = this.inbox;
return getInboxIconByType(type, medium, 'line');
},
bannerMaxWidth() {
const narrowTabs = [
'collaborators',
'configuration',
'bot-configuration',
];
if (narrowTabs.includes(this.selectedTabKey)) return 'max-w-4xl';
if (this.selectedTabKey === 'inbox-settings') {
return this.isAWebWidgetInbox ? 'max-w-7xl' : 'max-w-4xl';
}
return 'max-w-7xl';
},
inboxName() {
if (this.isATwilioSMSChannel || this.isATwilioWhatsAppChannel) {
return `${this.inbox.name} (${
@@ -571,44 +583,57 @@ export default {
v-if="microsoftUnauthorized"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<FacebookReauthorize
v-if="facebookUnauthorized"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<GoogleReauthorize
v-if="googleUnauthorized"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<InstagramReauthorize
v-if="instagramUnauthorized"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<TiktokReauthorize
v-if="tiktokUnauthorized"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<WhatsappReauthorize
v-if="whatsappUnauthorized"
:whatsapp-registration-incomplete="whatsappRegistrationIncomplete"
:inbox="inbox"
class="mb-4"
:class="bannerMaxWidth"
/>
<DuplicateInboxBanner
v-if="hasDuplicateInstagramInbox"
:content="$t('INBOX_MGMT.ADD.INSTAGRAM.DUPLICATE_INBOX_BANNER')"
class="mx-6 mb-4"
:class="bannerMaxWidth"
/>
<div
v-if="selectedTabKey === 'inbox-settings'"
class="flex flex-col md:flex-row items-center lg:items-start justify-between gap-5 lg:gap-10 mx-6"
>
<div class="max-w-2xl flex-1 flex flex-col min-w-0">
<div
class="flex-1 flex flex-col min-w-0"
:class="{
'max-w-2xl': isAWebWidgetInbox,
'max-w-4xl': !isAWebWidgetInbox,
}"
>
<div class="flex flex-col gap-1 items-start mb-4">
<label class="text-heading-3 text-n-slate-12">
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL') }}
@@ -755,6 +780,7 @@ export default {
<SenderNameExamplePreview
:sender-name-type="senderNameType"
:business-name="businessName"
:is-website-channel="isAWebWidgetInbox"
@update="toggleSenderNameType"
/>
</template>
@@ -1107,10 +1133,10 @@ export default {
</div>
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-3xl">
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-4xl">
<CollaboratorsPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'configuration'">
<div v-if="selectedTabKey === 'configuration'" class="mx-6 max-w-4xl">
<ConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">
@@ -147,7 +147,9 @@ export default {
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE'));
useAlert(
error.message || this.$t('INBOX_MGMT.SMTP.EDIT.ERROR_MESSAGE')
);
}
},
},
@@ -175,7 +177,7 @@ export default {
<woot-input
v-model="address"
:class="{ error: v$.address.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.ADDRESS.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.ADDRESS.PLACE_HOLDER')"
@blur="v$.address.$touch"
@@ -184,7 +186,7 @@ export default {
v-model="port"
type="number"
:class="{ error: v$.port.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.PORT.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PORT.PLACE_HOLDER')"
@blur="v$.port.$touch"
@@ -192,7 +194,7 @@ export default {
<woot-input
v-model="login"
:class="{ error: v$.login.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
@blur="v$.login.$touch"
@@ -200,7 +202,7 @@ export default {
<woot-input
v-model="password"
:class="{ error: v$.password.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.PASSWORD.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.PASSWORD.PLACE_HOLDER')"
type="password"
@@ -209,7 +211,7 @@ export default {
<woot-input
v-model="domain"
:class="{ error: v$.domain.$error }"
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.DOMAIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.DOMAIN.PLACE_HOLDER')"
@blur="v$.domain.$touch"
@@ -220,14 +222,14 @@ export default {
:action="handleEncryptionChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.OPEN_SSL_VERIFY_MODE')"
:selected="openSSLVerifyMode"
:options="openSSLVerifyModes"
:action="handleSSLModeChange"
/>
<SingleSelectDropdown
class="max-w-[75%] w-full"
class="w-full"
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
:selected="authMechanism"
:options="authMechanisms"
@@ -84,7 +84,7 @@ export default {
</script>
<template>
<div class="mx-6 max-w-3xl">
<div class="mx-6 max-w-4xl">
<LoadingState v-if="uiFlags.isFetching || uiFlags.isFetchingAgentBot" />
<form v-else @submit.prevent="updateActiveAgentBot">
<SettingsFieldSection
@@ -13,6 +13,10 @@ const props = defineProps({
type: String,
default: '',
},
isWebsiteChannel: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update']);
@@ -56,7 +60,10 @@ const toggleSenderNameType = key => {
<template>
<div
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
class="flex flex-col items-start gap-4 mt-3 min-w-0"
:class="
isWebsiteChannel ? 'sm:flex-row md:flex-col xl:flex-row' : 'sm:flex-row'
"
>
<RadioCard
v-for="keyOption in senderNameKeyOptions"
@@ -149,7 +149,7 @@ export default {
</script>
<template>
<div v-if="isATwilioChannel" class="mx-6">
<div v-if="isATwilioChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:help-text="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
@@ -168,7 +168,7 @@ export default {
</NextButton>
</SettingsFieldSection>
</div>
<div v-else-if="isAVoiceChannel" class="mx-6">
<div v-else-if="isAVoiceChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
:help-text="
@@ -187,7 +187,7 @@ export default {
</SettingsFieldSection>
</div>
<div v-else-if="isALineChannel" class="mx-6">
<div v-else-if="isALineChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
:help-text="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
@@ -196,7 +196,7 @@ export default {
</SettingsFieldSection>
</div>
<div v-else-if="isAWebWidgetInbox">
<div class="mx-6">
<div>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
@@ -266,7 +266,7 @@ export default {
</SettingsFieldSection>
</div>
</div>
<div v-else-if="isAPIInbox" class="mx-6">
<div v-else-if="isAPIInbox">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.INBOX_IDENTIFIER_SUB_TEXT')"
@@ -297,7 +297,7 @@ export default {
</div>
</SettingsFieldSection>
</div>
<div v-else-if="isAnEmailChannel" class="mx-6">
<div v-else-if="isAnEmailChannel">
<div>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.FORWARD_EMAIL_TITLE')"
@@ -325,7 +325,7 @@ export default {
<SmtpSettings v-if="inbox.imap_enabled" :inbox="inbox" />
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-6">
<div v-if="inbox.provider_config">
<!-- Embedded Signup Section -->
<template v-if="isEmbeddedSignupWhatsApp">
<SettingsFieldSection
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useInbox } from 'dashboard/composables/useInbox';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { CSAT_DISPLAY_TYPES } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
@@ -26,6 +27,7 @@ const props = defineProps({
const { t } = useI18n();
const store = useStore();
const labels = useMapGetter('labels/getLabels');
const { captainEnabled } = useCaptain();
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
props.inbox?.id
@@ -37,6 +39,8 @@ const isAnyWhatsAppChannel = computed(
);
const isUpdating = ref(false);
const utilityAnalysisLoading = ref(false);
const utilityAnalysisResult = ref(null);
const selectedLabelValues = ref([]);
const currentLabel = ref('');
@@ -46,7 +50,7 @@ const state = reactive({
message: '',
templateButtonText: 'Please rate us',
surveyRuleOperator: 'contains',
templateLanguage: '',
templateLanguage: 'en',
});
const templateStatus = ref(null);
@@ -89,6 +93,9 @@ const messagePreviewData = computed(() => ({
const shouldShowTemplateStatus = computed(
() => templateStatus.value && !templateLoading.value
);
const showUtilityAnalyzer = computed(
() => isAnyWhatsAppChannel.value && captainEnabled.value
);
const templateApprovalStatus = computed(() => {
const statusMap = {
@@ -218,6 +225,85 @@ const updateDisplayType = type => {
state.displayType = type;
};
const resetUtilityAnalysis = () => {
utilityAnalysisResult.value = null;
};
const analyzeTemplateUtility = async () => {
if (!showUtilityAnalyzer.value || !state.message?.trim()) return;
utilityAnalysisLoading.value = true;
resetUtilityAnalysis();
try {
const response = await store.dispatch(
'inboxes/analyzeCSATTemplateUtility',
{
inboxId: props.inbox.id,
template: {
message: state.message,
button_text: state.templateButtonText,
language: state.templateLanguage,
},
}
);
utilityAnalysisResult.value = response;
} catch (error) {
const errorMessage =
error.response?.data?.error ||
t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ERROR_MESSAGE');
useAlert(errorMessage);
} finally {
utilityAnalysisLoading.value = false;
}
};
const applyUtilitySuggestion = () => {
const suggestion = utilityAnalysisResult.value?.optimized_message;
if (!suggestion) return;
state.message = suggestion;
resetUtilityAnalysis();
};
watch(
() => [state.message, state.templateButtonText, state.templateLanguage],
(newValues, oldValues) => {
if (!oldValues || !utilityAnalysisResult.value) {
return;
}
const changed = newValues.some(
(value, index) => value !== oldValues[index]
);
if (changed) {
resetUtilityAnalysis();
}
}
);
const getUtilityClassificationLabel = classification => {
if (classification === 'LIKELY_UTILITY') {
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_UTILITY');
}
if (classification === 'LIKELY_MARKETING') {
return t(
'INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.LIKELY_MARKETING'
);
}
return t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.CLASSIFICATION.UNCLEAR');
};
const getUtilityClassificationClass = classification => {
if (classification === 'LIKELY_UTILITY') {
return 'bg-n-teal-3 text-n-teal-11';
}
if (classification === 'LIKELY_MARKETING') {
return 'bg-n-ruby-3 text-n-ruby-11';
}
return 'bg-n-amber-3 text-n-amber-11';
};
const updateSurveyRuleOperator = operator => {
state.surveyRuleOperator = operator;
};
@@ -450,6 +536,70 @@ const handleConfirmTemplateUpdate = async () => {
class="w-full"
/>
</WithLabel>
<div v-if="showUtilityAnalyzer" class="flex flex-col gap-2">
<NextButton
sm
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.ACTION')"
:is-loading="utilityAnalysisLoading"
:disabled="!state.message?.trim()"
@click="analyzeTemplateUtility"
/>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.HELPER_NOTE') }}
</p>
</div>
<div
v-if="utilityAnalysisResult"
class="flex flex-col gap-3 p-3 rounded-xl outline outline-1 outline-n-weak bg-n-alpha-1"
>
<div class="flex gap-2 items-center">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.RESULT_LABEL') }}
</span>
<span
class="px-2 py-0.5 text-xs font-medium rounded-full"
:class="
getUtilityClassificationClass(
utilityAnalysisResult.classification
)
"
>
{{
getUtilityClassificationLabel(
utilityAnalysisResult.classification
)
}}
</span>
</div>
<p class="text-xs text-n-slate-11">
{{ $t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.GUIDANCE_NOTE') }}
</p>
<div
v-if="
utilityAnalysisResult.optimized_message &&
utilityAnalysisResult.classification !== 'LIKELY_UTILITY'
"
class="flex flex-col gap-2"
>
<p class="text-xs font-medium text-n-slate-12">
{{
$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.SUGGESTION_LABEL')
}}
</p>
<p class="text-sm text-n-slate-12">
{{ utilityAnalysisResult.optimized_message }}
</p>
<NextButton
sm
faded
slate
:label="$t('INBOX_MGMT.CSAT.UTILITY_ANALYZER.APPLY')"
@click="applyUtilitySuggestion"
/>
</div>
</div>
<Input
v-model="state.templateButtonText"
:label="$t('INBOX_MGMT.CSAT.BUTTON_TEXT.LABEL')"
@@ -51,6 +51,7 @@ defineEmits(['edit', 'delete']);
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="$emit('delete', app)"
/>
</div>
@@ -133,6 +133,7 @@ const inboxName = hook => (hook.inbox ? hook.inbox.name : '');
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="$emit('delete', hook)"
/>
</div>
@@ -73,6 +73,7 @@ const subscribedEvents = computed(() => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="emit('delete', webhook, index)"
/>
</div>
@@ -174,6 +174,7 @@ onBeforeMount(() => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[label.id]"
@click="openDeletePopup(label)"
/>
@@ -96,6 +96,7 @@ const visibilityLabel = computed(() => {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
@click="$emit('delete')"
/>
</div>
@@ -288,6 +288,7 @@ export default {
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[sla.id]"
@click="openDeletePopup(sla)"
/>
@@ -88,19 +88,14 @@ export default {
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
>
<div class="w-full">
<PageHeader
:header-title="headerTitle"
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
/>
</div>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
:header-content="$t('TEAMS_SETTINGS.ADD.DESC')"
/>
<div class="w-full">
<div class="w-full h-full">
<div v-if="v$.selectedAgents.$error">
<p class="error-message pb-2">
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
@@ -37,7 +37,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<div class="h-full w-full p-6 col-span-6 overflow-y-auto">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.CREATE_FLOW.CREATE.DESC')"
@@ -25,7 +25,7 @@ export default {
<template>
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
@@ -103,19 +103,14 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6">
<form
class="flex flex-wrap mx-0 overflow-x-auto"
@submit.prevent="addAgents"
>
<div class="w-full">
<PageHeader
:header-title="headerTitle"
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
/>
</div>
<div class="h-full w-full p-8 col-span-6 overflow-auto">
<form class="flex flex-col gap-4 mx-0" @submit.prevent="addAgents">
<PageHeader
:header-title="headerTitle"
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.AGENTS.DESC')"
/>
<div class="w-full">
<div class="w-full h-full">
<div v-if="v$.selectedAgents.$error">
<p class="error-message pb-2">
{{ $t('TEAMS_SETTINGS.ADD.AGENT_VALIDATION_ERROR') }}
@@ -57,7 +57,7 @@ export default {
</script>
<template>
<div class="h-full w-full p-8 col-span-6">
<div class="h-full w-full p-8 col-span-6 overflow-y-auto">
<PageHeader
:header-title="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.TITLE')"
:header-content="$t('TEAMS_SETTINGS.EDIT_FLOW.CREATE.DESC')"
@@ -29,7 +29,7 @@ export default {
<template>
<div class="flex flex-col gap-6 mb-8 max-w-7xl mx-auto w-full !px-6">
<div
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak min-h-[43rem] w-full"
class="grid grid-cols-1 lg:grid-cols-8 lg:divide-x lg:divide-n-weak rounded-xl border border-n-weak h-full min-h-[50dvh] w-full"
>
<woot-wizard
class="hidden lg:block col-span-2 h-fit py-8 px-6"
@@ -161,6 +161,7 @@ const confirmPlaceHolderText = computed(() =>
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
:is-loading="loading[team.id]"
@click="openDelete(team)"
/>
@@ -360,6 +360,13 @@ export const actions = {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
analyzeCSATTemplateUtility: async (_, { inboxId, template }) => {
const response = await InboxesAPI.analyzeCSATTemplateUtility(
inboxId,
template
);
return response.data;
},
};
export const mutations = {
@@ -361,4 +361,77 @@ describe('useNumberFormatter', () => {
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
});
});
describe('formatCompactWithDecimal', () => {
it('should return exact numbers for values under 1,000', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(0)).toBe('0');
expect(formatCompactWithDecimal(1)).toBe('1');
expect(formatCompactWithDecimal(42)).toBe('42');
expect(formatCompactWithDecimal(999)).toBe('999');
});
it('should return compact format with decimals for thousands', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1000)).toBe('1K');
expect(formatCompactWithDecimal(1500)).toBe('1.5K');
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
expect(formatCompactWithDecimal(15000)).toBe('15K');
expect(formatCompactWithDecimal(15500)).toBe('15.5K');
});
it('should return compact format with decimals for millions', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1000000)).toBe('1M');
expect(formatCompactWithDecimal(1200000)).toBe('1.2M');
expect(formatCompactWithDecimal(1234000)).toBe('1.2M');
expect(formatCompactWithDecimal(2500000)).toBe('2.5M');
expect(formatCompactWithDecimal(10000000)).toBe('10M');
});
it('should handle edge cases gracefully', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(null)).toBe('0');
expect(formatCompactWithDecimal(undefined)).toBe('0');
expect(formatCompactWithDecimal(NaN)).toBe('0');
expect(formatCompactWithDecimal('string')).toBe('0');
});
it('should handle negative numbers', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(-500)).toBe('-500');
expect(formatCompactWithDecimal(-1500)).toBe('-1.5K');
expect(formatCompactWithDecimal(-29400)).toBe('-29.4K');
expect(formatCompactWithDecimal(-1200000)).toBe('-1.2M');
});
it('should respect custom decimal places', () => {
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(1234, 2)).toBe('1.23K');
expect(formatCompactWithDecimal(1234567, 2)).toBe('1.23M');
});
it('should format with de-DE locale', () => {
const mockLocale = ref('de-DE');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
// German uses different compact notation
const result = formatCompactWithDecimal(29400);
expect(result).toMatch(/29[,.]?4/);
});
it('should handle underscore-based locale tags', () => {
const mockLocale = ref('en_US');
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
});
it('should handle null/undefined locale gracefully', () => {
const mockLocale = ref(null);
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
const { formatCompactWithDecimal } = useNumberFormatter();
expect(formatCompactWithDecimal(29400)).toBe('29.4K');
});
});
});
@@ -60,8 +60,37 @@ export function useNumberFormatter() {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
};
/**
* Format a number with decimal precision using compact notation (standard Intl approach)
* - Up to 1,000: show exact number (e.g., 999)
* - 1,000+: show compact with decimals (e.g., 29400 "29.4K", 1500 "1.5K")
* - 1,000,000+: show as "X.XM" (e.g., 1,234,000 "1.2M")
*
* Uses browser-native Intl.NumberFormat for proper localization
*
* @param {number} num - The number to format
* @param {number} [decimals=1] - Maximum decimal places to show
* @returns {string} Formatted number string with decimal precision
*/
const formatCompactWithDecimal = (num, decimals = 1) => {
if (typeof num !== 'number' || Number.isNaN(num)) {
return '0';
}
if (Math.abs(num) < 1000) {
return new Intl.NumberFormat(resolvedLocale.value).format(num);
}
return new Intl.NumberFormat(resolvedLocale.value, {
notation: 'compact',
compactDisplay: 'short',
maximumFractionDigits: decimals,
}).format(num);
};
return {
formatCompactNumber,
formatFullNumber,
formatCompactWithDecimal,
};
}
@@ -24,7 +24,7 @@ describe('GoogleOAuthButton.vue', () => {
const googleAuthUrl = new URL(wrapper.vm.getGoogleAuthUrl());
const params = googleAuthUrl.searchParams;
expect(googleAuthUrl.origin).toBe('https://accounts.google.com');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth/oauthchooseaccount');
expect(googleAuthUrl.pathname).toBe('/o/oauth2/auth');
expect(params.get('client_id')).toBe('clientId');
expect(params.get('redirect_uri')).toBe(
'http://localhost:3000/test-callback'
@@ -6,8 +6,7 @@ export default {
// Creating the URL manually because the devise-token-auth with
// omniauth has a standing issue on redirecting the post request
// https://github.com/lynndylanhurley/devise_token_auth/issues/1466
const baseUrl =
'https://accounts.google.com/o/oauth2/auth/oauthchooseaccount';
const baseUrl = 'https://accounts.google.com/o/oauth2/auth';
const clientId = window.chatwootConfig.googleOAuthClientId;
const redirectUri = window.chatwootConfig.googleOAuthCallbackUrl;
const responseType = 'code';
@@ -1,6 +1,7 @@
module ActivityMessageHandler
extend ActiveSupport::Concern
include AssigneeActivityMessageHandler
include PriorityActivityMessageHandler
include LabelActivityMessageHandler
include SlaActivityMessageHandler
@@ -104,27 +105,6 @@ module ActivityMessageHandler
content = I18n.t("conversations.activity.#{change_type}", user_name: Current.user.name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_assignee_change_activity_content(user_name)
params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
end
def create_assignee_change_activity(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
content = generate_assignee_change_activity_content(user_name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def activity_message_owner(user_name)
user_name = I18n.t('automation.system_name') if !user_name && Current.executed_by.present?
user_name
end
end
ActivityMessageHandler.prepend_mod_with('ActivityMessageHandler')
@@ -0,0 +1,35 @@
module AssigneeActivityMessageHandler
extend ActiveSupport::Concern
private
def create_assignee_change_activity(user_name)
user_name = activity_message_owner(user_name)
return unless user_name
content = generate_assignee_change_activity_content(user_name)
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end
def generate_assignee_change_activity_content(user_name)
params = { assignee_name: assignee&.name || '', user_name: user_name }
key = assignee_id ? 'assigned' : 'removed'
key = 'self_assigned' if self_assign? assignee_id
I18n.t("conversations.activity.assignee.#{key}", **params)
end
def activity_message_owner(user_name)
if !user_name && Current.executed_by.present?
user_name = case Current.executed_by
when AssignmentPolicy
I18n.t('auto_assignment.policy_actor', policy_name: Current.executed_by.name)
when Inbox
I18n.t('auto_assignment.default_policy_name')
else
I18n.t('automation.system_name')
end
end
user_name
end
end
@@ -14,7 +14,7 @@ module MessageFilterHelpers
end
def notifiable?
incoming? || outgoing?
(incoming? || outgoing?) && !private?
end
def conversation_transcriptable?

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