Compare commits

..
Author SHA1 Message Date
Muhsin 63abd18bde chore: remove the guides 2026-03-04 20:38:11 +04:00
Muhsin c3606d22ea chore: update docs 2026-02-28 17:54:29 +04:00
Muhsin db96d0a34c chore: shopify tool calling 2026-02-28 17:47:37 +04:00
Muhsin KelothandGitHub 7f5d9f0f76 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-28 10:45:31 +04:00
c08fa631a9 feat: Add temporary account setting to disable Captain auto-resolve (#13680)
Add a temporary `captain_disable_auto_resolve` boolean setting on
accounts to prevent Captain from resolving conversations. Guards both
the scheduled resolution job and the assistant's resolve tool.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:37:00 -08:00
14b4c83dc6 fix: Prevent AudioTranscriptionJob from crashing on OpenAI 401 error (#13653)
Describe the bug
In v4.8.0, when an audio message is received, the system enqueues
Messages::AudioTranscriptionJob even if OpenAI and Captain are disabled.
This causes a Faraday::UnauthorizedError (401) which crashes the Sidekiq
job and breaks the pipeline for that message.

To Reproduce
Disable OpenAI/Captain integrations.

Send an audio message to an inbox.

Check Sidekiq logs and observe the 401 crash in
AudioTranscriptionService.

What this PR does
Adds a rescue Faraday::UnauthorizedError block inside
AudioTranscriptionService#perform. Instead of crashing the worker, it
logs a warning and gracefully exits, allowing the job to complete
successfully.

Note: This fixes the backend crash. However, there is still a frontend
reactivity issue where the audio player UI requires an F5 to load the
media, which has been reported in Issue #11013.

---------

Co-authored-by: Eloi Junior Seganfredo <eloi@seganfredo.local>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-27 14:12:03 +04:00
df92fd12cb fix: bot handoff should set waiting time (#13417)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2026-02-27 15:31:49 +05:30
Sojan JoseandGitHub d84ae196d5 fix: call authorization_error! on IMAP auth failures (#13560) (revert) (#13671)
This reverts commit 7acd239c70 to further
debug upstream issues.
2026-02-26 18:45:18 -08:00
Muhsin KelothandGitHub bdcc62f1b0 feat(facebook): Mark Messenger native-app echoes as external echo message (#13665)
When agents send replies from the native Facebook Messenger app (not
Chatwoot), echo events were created without external_echo metadata and
could be misrepresented in the UI. This change updates Messenger echo
message creation to:

- set content_attributes.external_echo = true for outgoing_echo messages
- set echo message status to delivered
- keep sender as nil for echo messages (existing behavior)

<img width="2614" height="1264" alt="CleanShot 2026-02-26 at 16 32
04@2x"
src="https://github.com/user-attachments/assets/ba61c941-465d-4893-814e-855e6b6c79e8"
/>
2026-02-26 19:05:15 +04:00
Tanmay Deep SharmaandGitHub 7acd239c70 fix: call authorization_error! on IMAP auth failures (#13560)
## Notion document

https://www.notion.so/chatwoot/Email-IMAP-Issue-30aa5f274c928062aa6bddc2e5877a63?showMoveTo=true&saveParent=true

## Description

PLAIN IMAP channels (non-OAuth) were silently retrying failed
authentication every minute, forever. When credentials are
wrong/expired, Net::IMAP::NoResponseError was caught and logged but
channel.authorization_error! was never called — so the Redis error
counter never incremented, reauthorization_required? was never set, and
admins were never notified. OAuth channels already had this handled
correctly via the Reauthorizable concern.
Additionally, Net::IMAP::ResponseParseError (raised by non-RFC-compliant
IMAP servers) was falling through to the StandardError catch-all,
flooding
Estimated impact before fix: ~70–75 broken IMAP inboxes generating
~700k–750k wasted Sidekiq jobs/week.

## Type of change

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

## 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
2026-02-26 18:01:23 +05:30
Tanmay Deep SharmaandGitHub 9ca03c1af3 chore: make all the deprecated feature flag reclaimable (#13646)
## Docs

https://www.notion.so/chatwoot/Redeeming-a-depreciated-feature-flag-313a5f274c9280f381cdd811eab42019?source=copy_link

## Description
Marks 8 unused feature flags as deprecated: true in features.yml,
freeing their bit slots for future reuse.
Removes dead code references from JS constants, help URLs, and
enterprise billing config.

## Type of change

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

## How Has This Been Tested?

- Simulated the "claim a slot" workflow 

## 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
2026-02-26 18:01:13 +05:30
Shivam MishraandGitHub c218eff5ec feat: add per-webhook secret with backfill migration (#13573) 2026-02-26 17:26:12 +05:30
7c60ad9e28 feat: include contact verified status with each tool call (#13663)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-02-26 16:16:33 +05:30
Muhsin KelothandGitHub 6b3f1114fd fix(slack): Show correct sender name and avatar for Slack replies (#13624) 2026-02-26 16:15:15 +05:30
Sivin VargheseandGitHub 109b43aadb chore: Disable API channel reply editor outside 24h window (#13664) 2026-02-26 16:05:05 +05:30
Vishnu NarayananandGitHub 3ddab3ab26 fix: show upgrade prompt when email transcript returns 402 (#13650)
- Show a specific upgrade prompt when free-plan users attempt to send an
email transcript and the API returns a 402 Payment Required error
- Previously, a generic "There was an error, please try again" message
was shown for all failures, including plan restrictions

Fixes
https://linear.app/chatwoot/issue/CW-6538/show-ui-feedback-for-email-transcript-402-plan-restriction
2026-02-26 12:54:40 +05:30
PranavandGitHub e2dd2ccb42 feat: Add a priority + created at sort for conversations (#13658)
- Add a new conversation sort option "Priority: Highest first, Created:
Oldest first" that sorts by priority descending (urgent > high > medium
> low > none) with created_at ascending as the tiebreaker
2026-02-25 18:22:41 -08:00
9fab70aebf fix: Use search API instead of filter in the filter in the endpoints (#13651)
- Replace `POST /contacts/filter` with `GET /contacts/search` for
contact lookup in compose new conversation
- Remove client-side input-type detection logic (`generateContactQuery`,
key filtering by email/phone/name) — the search API handles matching
across name, email, phone_number, and identifier server-side via a
single `ILIKE` query
- Filter the contacts with emails in cc and bcc fields.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-02-25 09:08:24 -08:00
Aakash BakhleandGitHub efe49f7da4 fix: captain liquid render file system (#13647) 2026-02-25 20:19:34 +05:30
Sivin VargheseandGitHub 5aef9d2dd0 fix: Conversation list overlay issue with Virtua virtualizer (#13648) 2026-02-25 20:18:34 +05:30
b98c614669 feat: add campaign context to Captain v2 prompts (#13644)
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2026-02-25 18:33:37 +05:30
Sivin VargheseandGitHub ba804e0f30 fix: Upgrade pico-search to 0.6.0 (#13645) 2026-02-25 16:52:45 +05:30
a44cb2c738 feat(inbox): Enable conversation continuity for social channels (#11079)
## Summary
This PR enables and surfaces **conversation workflow** for social-style
channels that should support either:
- `Create new conversations` after resolve, or
- `Reopen same conversation`

## What is included
- Adds the conversation workflow setting UI as card-based options in
Inbox Settings.
- Expands channel availability in settings to include channels like:
  - Telegram
  - TikTok
  - Instagram
  - Line
  - WhatsApp
  - Facebook
- Updates conversation selection behavior for Line incoming messages to
respect the workflow (reopen vs create-new-after-resolved).
- Updates TikTok conversation selection behavior to respect the workflow
(reopen vs create-new-after-resolved).
- Keeps email behavior unchanged (always starts a new thread).

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

## Screenshot

<img width="1400" height="900" alt="pr11079-workflow-sender-clear-tight"
src="https://github.com/user-attachments/assets/9456821f-8d83-4924-8dcf-7503c811a7b1"
/>


## How To Reproduce
1. Open `Settings -> Inboxes ->
<Telegram/TikTok/Instagram/Line/Facebook/WhatsApp inbox> -> Settings`.
2. Verify **Conversation workflow** is visible with the two card
options.
3. Toggle between both options and save.
4. For Line and TikTok, verify resolved-conversation behavior follows
the selected workflow.

## Testing
- `RAILS_ENV=test bundle exec rspec
spec/builders/messages/instagram/message_builder_spec.rb:213
spec/builders/messages/instagram/message_builder_spec.rb:255
spec/builders/messages/instagram/messenger/message_builder_spec.rb:228
spec/builders/messages/instagram/messenger/message_builder_spec.rb:293
spec/services/tiktok/message_service_spec.rb`
- Result: `16 examples, 0 failures`

## Follow-up
- Migrate Website Live Chat workflow settings into this same
conversation-workflow settings model.
- Add Voice channel support for this workflow setting.

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2026-02-25 13:56:51 +04:00
Sivin VargheseandGitHub 172ff87b5b feat: Replace vue-virtual-scroller with virtua for chat list virtualization (#13642)
# Pull Request Template

## Description

This PR replaces `vue-virtual-scroller` with
[`virtua`](https://github.com/inokawa/virtua/#benchmark) for the
conversation list virtualization.

### Changes
- Replace `vue-virtual-scroller`
(`DynamicScroller`/`DynamicScrollerItem`) with `virtua`'s `Virtualizer`
component
- Remove `IntersectionObserver`-based infinite scroll in favor of
`Virtualizer`'s `@scroll` event with offset-based bottom detection
- Remove `useEventListener` scroll binding and
`intersectionObserverOptions` computed
- Simplify item rendering — no more `DynamicScrollerItem` wrapper or
`size-dependencies` tracking; `virtua` measures items automatically


## Type of change

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


## 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-25 12:59:02 +04:00
Sojan JoseandGitHub 55f6257313 chore(hub): clean up legacy Captain hub flow (#13640)
## Summary
This PR cleans up legacy Hub/Captain integration paths and simplifies
hub URL behavior coverage in tests.

## Changes
- remove legacy Captain account endpoint flow from `ChatwootHub`
- remove obsolete spec coverage tied to that retired flow
- keep hub URL handling centralized in `base_url` with enterprise
overlay precedence
- simplify hub URL specs to assert explicit static URL expectations
where applicable

## Reproduce
Run the focused hub specs from this branch:
- `bundle exec rspec spec/lib/chatwoot_hub_spec.rb
spec/enterprise/lib/chatwoot_hub_spec.rb`

## Testing
Validated locally with:
- `bundle exec rspec spec/lib/chatwoot_hub_spec.rb
spec/enterprise/lib/chatwoot_hub_spec.rb`
- `bundle exec rubocop lib/chatwoot_hub.rb spec/lib/chatwoot_hub_spec.rb
enterprise/lib/enterprise/chatwoot_hub.rb
spec/enterprise/lib/chatwoot_hub_spec.rb`
2026-02-24 20:29:53 -08:00
Muhsin KelothandGitHub 76f129efaf feat(tiktok): Enable outgoing image attachments (#13620)
- Enabled the attachment button for TikTok conversations in the reply
box
- Auto-split messages when both text and an image are composed together,
since the TikTok API rejects mixed text+media in a single message.
Fixes
https://linear.app/chatwoot/issue/CW-6528/enable-outgoing-image-attachments
2026-02-24 20:13:58 +04:00
Muhsin KelothandGitHub 3281f43629 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-24 12:54:47 +04:00
Muhsin KelothandGitHub 97fb06dba3 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-23 14:44:54 +04:00
Muhsin eec285e41d Update Shopify.vue 2026-02-19 16:05:44 +05:30
Muhsin KelothandGitHub bfc6cd1c0f Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-19 16:02:22 +05:30
Muhsin 09812b9343 chore: update integration page 2026-02-19 16:00:53 +05:30
Muhsin KelothandGitHub 00956845bc Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-17 20:39:10 +05:30
Muhsin 228992549b chore: fix specs 2026-02-17 20:37:22 +05:30
Muhsin e496bd9cb1 chore: fix integration 2026-02-17 19:29:28 +05:30
Muhsin 4791cef4d7 Update callbacks_controller.rb 2026-02-17 18:58:11 +05:30
Muhsin cfafab0f08 chore: fix security issues 2026-02-17 18:01:44 +05:30
Muhsin 24b0241ba5 chore: make changes 2026-02-17 17:49:33 +05:30
Muhsin 81afdf0703 chore: add ttl helper to Redis::Alfred 2026-02-17 17:13:06 +05:30
Muhsin d4e82e6c4c Update shopify_controller_spec.rb 2026-02-17 17:07:52 +05:30
Muhsin KelothandGitHub d3bf813067 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-17 17:00:03 +05:30
Muhsin 5c58cd5e89 feat: add SHOPIFY_APP_STORE_URL 2026-02-17 16:59:25 +05:30
Muhsin KelothandGitHub 547d58fefb Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-17 15:50:45 +05:30
Muhsin a8367a969f chore: fix specs 2026-02-17 15:41:15 +05:30
Muhsin KelothandGitHub ab3236148a Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-17 14:33:41 +05:30
Muhsin KelothandGitHub 7143482a87 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-17 11:33:30 +05:30
Muhsin KelothandGitHub bd59e7ec04 Merge branch 'develop' into feat/shopify-app-store-install-flow 2026-02-16 13:51:51 +05:30
Muhsin 434d621dc4 feat(shopify): Support Shopify-initiated install flow and improve integration page 2026-02-13 13:14:04 +05:30
179 changed files with 5375 additions and 4402 deletions
@@ -105,15 +105,19 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
def message_params
content_attributes = {
in_reply_to_external_id: response.in_reply_to_external_id
}
content_attributes[:external_echo] = true if @outgoing_echo
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: @message_type,
status: @outgoing_echo ? :delivered : :sent,
content: response.content,
source_id: response.identifier,
content_attributes: {
in_reply_to_external_id: response.in_reply_to_external_id
},
content_attributes: content_attributes,
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@@ -1,26 +1,9 @@
class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::BaseController
include Shopify::IntegrationHelper
before_action :setup_shopify_context, only: [:orders]
before_action :fetch_hook, except: [:auth]
before_action :fetch_hook, except: [:complete_install]
before_action :validate_contact, only: [:orders]
def auth
shop_domain = params[:shop_domain]
return render json: { error: 'Shop domain is required' }, status: :unprocessable_entity if shop_domain.blank?
state = generate_shopify_token(Current.account.id)
auth_url = "https://#{shop_domain}/admin/oauth/authorize?"
auth_url += URI.encode_www_form(
client_id: client_id,
scope: REQUIRED_SCOPES.join(','),
redirect_uri: redirect_uri,
state: state
)
render json: { redirect_url: auth_url }
end
def orders
customers = fetch_customers
return render json: { orders: [] } if customers.empty?
@@ -31,6 +14,23 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
render json: { error: e.message }, status: :unprocessable_entity
end
def complete_install
token_key = "shopify_pending_install:#{params[:pending_install_token]}"
data = claim_pending_install_token(token_key, Current.account.id)
return render json: { error: data[:error] }, status: :unprocessable_entity if data[:error]
Current.account.hooks.create!(
app_id: 'shopify',
access_token: data['access_token'],
status: 'enabled',
reference_id: data['shop'],
settings: { scope: data['scope'] }
)
::Redis::Alfred.delete(token_key)
head :ok
end
def destroy
@hook.destroy!
head :ok
@@ -40,10 +40,6 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
private
def redirect_uri
"#{ENV.fetch('FRONTEND_URL', '')}/shopify/callback"
end
def contact
@contact ||= Current.account.contacts.find_by(id: params[:contact_id])
end
@@ -108,4 +104,31 @@ class Api::V1::Accounts::Integrations::ShopifyController < Api::V1::Accounts::Ba
render json: { error: 'Contact information missing' },
status: :unprocessable_entity
end
def claim_pending_install_token(token_key, account_id)
json_data = Redis::SecureStorage.get(token_key)
return { error: 'Invalid or expired install token' } if json_data.blank?
begin
data = JSON.parse(json_data)
rescue JSON::ParserError
return { error: 'Invalid or corrupted install token' }
end
if data['claimed']
Redis::SecureStorage.delete(token_key)
return { error: 'Install token already used' }
end
# Check if already bound to a different account (prevents token theft)
return { error: 'Install token cannot be used by this account' } if data['account_id'].present? && data['account_id'] != account_id
# Bind to this account and mark as claimed to prevent replay
data['claimed'] = true
data['account_id'] = account_id
ttl = ::Redis::Alfred.ttl(token_key)
Redis::SecureStorage.set(token_key, data, [ttl, 60].max) if ttl.positive?
data
end
end
+91 -25
View File
@@ -2,42 +2,74 @@ class Shopify::CallbacksController < ApplicationController
include Shopify::IntegrationHelper
def show
verify_account!
@response = oauth_client.auth_code.get_token(
params[:code],
redirect_uri: '/shopify/callback'
)
handle_response
if chatwoot_initiated?
handle_chatwoot_initiated_flow
else
handle_shopify_initiated_flow
end
rescue StandardError => e
Rails.logger.error("Shopify callback error: #{e.message}")
redirect_to "#{redirect_uri}?error=true"
redirect_to error_redirect_url
end
private
def verify_account!
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
def chatwoot_initiated?
verify_shopify_token(params[:state]).present?
end
def handle_response
def handle_chatwoot_initiated_flow
@account_id = verify_shopify_token(params[:state])
raise StandardError, 'Invalid state parameter' if account.blank?
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
create_hook
redirect_to shopify_integration_url
end
def handle_shopify_initiated_flow
raise StandardError, 'Invalid shop domain' unless valid_shop_domain?
raise StandardError, 'Invalid HMAC signature' unless valid_hmac?
# Security: HMAC validation ensures params (including shop) haven't been tampered with.
# Additionally, the OAuth code is cryptographically bound to the shop that issued it.
# Shopify will reject any attempt to exchange a code at a different shop's endpoint.
@response = oauth_client.auth_code.get_token(params[:code], redirect_uri: redirect_callback_uri)
token_key = SecureRandom.hex(16)
pending_data = {
access_token: parsed_body['access_token'],
shop: params[:shop],
scope: parsed_body['scope'],
claimed: false
}
Redis::SecureStorage.set("shopify_pending_install:#{token_key}", pending_data, 10.minutes)
redirect_url = "settings/integrations/shopify?shopify_pending_install=#{CGI.escape(token_key)}"
redirect_to "#{frontend_url}/app/login?redirect_url=#{CGI.escape(redirect_url)}", allow_other_host: true
end
def create_hook
account.hooks.create!(
app_id: 'shopify',
access_token: parsed_body['access_token'],
status: 'enabled',
reference_id: params[:shop],
settings: {
scope: parsed_body['scope']
}
settings: { scope: parsed_body['scope'] }
)
redirect_to shopify_integration_url
end
def parsed_body
@parsed_body ||= @response.response.parsed
@parsed_body ||= begin
parsed = @response.response.parsed
# Handle both SnakyHash (production) and regular Hash (tests)
{
'access_token' => parsed.respond_to?(:access_token) ? parsed.access_token : parsed['access_token'],
'scope' => parsed.respond_to?(:scope) ? parsed.scope : parsed['scope']
}
end
end
def oauth_client
@@ -56,17 +88,51 @@ class Shopify::CallbacksController < ApplicationController
@account ||= Account.find(@account_id)
end
def account_id
@account_id ||= params[:state].split('_').first
def redirect_callback_uri
"#{frontend_url}/shopify/callback"
end
def shopify_integration_url
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/shopify"
"#{frontend_url}/app/accounts/#{account.id}/settings/integrations/shopify"
end
def redirect_uri
return shopify_integration_url if account
def error_redirect_url
if @account_id
begin
"#{shopify_integration_url}?error=true"
rescue ActiveRecord::RecordNotFound
"#{frontend_url}?error=true"
end
else
"#{frontend_url}?error=true"
end
end
ENV.fetch('FRONTEND_URL', nil)
def frontend_url
ENV.fetch('FRONTEND_URL', '')
end
def valid_shop_domain?
return false if params[:shop].blank?
# Shopify shop domains must match: *.myshopify.com or *.myshopify.io (for dev shops)
params[:shop].match?(/\A[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.(com|io)\z/)
end
def valid_hmac?
return false if params[:hmac].blank?
# Shopify HMAC validation
# Reference: https://shopify.dev/docs/apps/build/authentication-authorization/get-access-tokens
hmac = params[:hmac]
# Build query string from params, excluding hmac and Rails-added params
query_params = params.except(:hmac, :controller, :action).to_unsafe_h
query_string = query_params.sort.map { |k, v| "#{k}=#{v}" }.join('&')
# Compute HMAC-SHA256
computed_hmac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('SHA256'), client_secret, query_string)
ActiveSupport::SecurityUtils.secure_compare(computed_hmac, hmac)
end
end
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
def allowed_configs
mapping = {
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET SHOPIFY_APP_STORE_URL],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
+1
View File
@@ -11,6 +11,7 @@ class ConversationFinder
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
+1 -1
View File
@@ -1,5 +1,5 @@
module Shopify::IntegrationHelper
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments].freeze
REQUIRED_SCOPES = %w[read_customers read_orders read_fulfillments read_products].freeze
# Generates a signed JWT token for Shopify integration
#
@@ -32,12 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,
});
}
}
export default new IntegrationsAPI();
@@ -12,6 +12,12 @@ class ShopifyAPI extends ApiClient {
params: { contact_id: contactId },
});
}
completeInstall(pendingInstallToken) {
return axios.post(`${this.url}/complete_install`, {
pending_install_token: pendingInstallToken,
});
}
}
export default new ShopifyAPI();
@@ -1,58 +0,0 @@
<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>
@@ -1,47 +0,0 @@
<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>
@@ -1,175 +0,0 @@
<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,42 +1,207 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
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>
<Icon
v-tooltip.top="{
content: priority,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
<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>
</template>
@@ -1,42 +0,0 @@
<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>
@@ -1,160 +0,0 @@
<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>
@@ -1,182 +0,0 @@
<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>
@@ -1,245 +0,0 @@
<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>
@@ -1,156 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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>
@@ -1,69 +0,0 @@
<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>
@@ -1,19 +0,0 @@
<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 || false
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open || false
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: !isCopilotPanelOpen.value,
is_copilot_panel_open: true,
});
};
@@ -1,100 +0,0 @@
<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>
@@ -1,36 +0,0 @@
<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>
@@ -1,86 +0,0 @@
<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>
@@ -158,21 +158,7 @@ const isAnyDropdownActive = computed(() => {
const handleContactSearch = value => {
showContactsDropdown.value = true;
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
emit('searchContacts', value);
};
const handleDropdownUpdate = (type, value) => {
@@ -187,12 +173,12 @@ const handleDropdownUpdate = (type, value) => {
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
emit('searchContacts', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
emit('searchContacts', value);
};
const setSelectedContact = async ({ value, action, ...rest }) => {
@@ -44,14 +44,16 @@ const bccEmailsArray = computed(() =>
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
return props.contacts
?.filter(contact => contact.email)
.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
@@ -176,32 +176,14 @@ export const prepareWhatsAppMessagePayload = ({
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
export const searchContacts = async query => {
const trimmed = typeof query === 'string' ? query.trim() : '';
if (!trimmed) return [];
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
} = await ContactAPI.search(trimmed);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
@@ -336,70 +336,6 @@ describe('composeConversationHelper', () => {
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
@@ -413,14 +349,11 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
const result = await helpers.searchContacts('john');
expect(result).toEqual([
{
@@ -432,16 +365,7 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
expect(ContactAPI.search).toHaveBeenCalledWith('john');
});
it('searches contacts and returns only contacts with email or phone number', async () => {
@@ -469,14 +393,11 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
const result = await helpers.searchContacts('john');
// Should only return contacts with either email or phone number
expect(result).toEqual([
@@ -496,20 +417,11 @@ describe('composeConversationHelper', () => {
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
expect(ContactAPI.search).toHaveBeenCalledWith('john');
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: [] },
});
@@ -536,7 +448,7 @@ describe('composeConversationHelper', () => {
},
];
ContactAPI.filter.mockResolvedValue({
ContactAPI.search.mockResolvedValue({
data: { payload: mockPayload },
});
@@ -11,13 +11,7 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
@@ -28,10 +22,6 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -52,17 +42,9 @@ 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', 'empty']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -114,13 +96,9 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
@@ -212,27 +190,22 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
rounded-full
/>
</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>
<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" />
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
@@ -241,9 +214,6 @@ 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"
@@ -263,27 +233,22 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
rounded-full
/>
</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>
<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" />
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
@@ -293,9 +258,7 @@ onMounted(() => {
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
@@ -39,45 +39,38 @@ 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: 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
// Case 3: object with a "name" property → return name
// Case 4: primitive (string, number, etc.) → return as is
return value?.name ?? value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-1.5 h-7"
class="inline-flex items-center gap-2 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 bg-n-button-color"
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"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="formatFilterValue(filter.values)"
v-if="filter.values"
:title="formatFilterValue(filter.values)"
class="lowercase truncate text-n-slate-12 font-440"
class="lowercase truncate text-n-slate-12"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
@@ -93,7 +86,7 @@ const formatFilterValue = value => {
"
>
<span
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
>
{{ filter.queryOperator }}
</span>
@@ -102,7 +95,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 font-440"
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"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
@@ -112,10 +105,9 @@ const formatFilterValue = value => {
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1 hover:!no-underline"
link
class="!px-1"
variant="ghost"
@click="emit('clearFilters')"
/>
<slot name="actions" />
</div>
</template>
@@ -1,14 +1,12 @@
<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';
@@ -44,13 +42,6 @@ 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 }];
};
@@ -113,80 +104,69 @@ const outsideClickHandler = [
<template>
<div
: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')"
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"
>
<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>
<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>
<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') }}
</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') }}
</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,10 +108,8 @@ 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' : 'info'"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
@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-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
@@ -119,13 +119,11 @@ const toggleOption = option => {
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate solid @click="toggle">
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11 truncate">{{
t('COMBOBOX.PLACEHOLDER')
}}</span>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
@@ -129,6 +129,7 @@ const props = defineProps({
inReplyTo: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
isEmailInbox: { type: Boolean, default: false },
private: { type: Boolean, default: false },
additionalAttributes: { type: Object, default: () => ({}) }, // eslint-disable-line vue/no-unused-properties
sender: { type: Object, default: null },
senderId: { type: Number, default: null },
senderType: { type: String, default: null },
@@ -172,7 +173,10 @@ const variant = computed(() => {
return MESSAGE_VARIANTS.AGENT;
}
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
const isBot =
props.sender?.type === SENDER_TYPES.AGENT_BOT ||
props.senderType === SENDER_TYPES.AGENT_BOT ||
(!props.sender && !props.additionalAttributes?.senderName);
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
return MESSAGE_VARIANTS.BOT;
}
@@ -450,12 +454,13 @@ const avatarInfo = computed(() => {
};
}
// If no sender, return bot info
// If no sender, check for Slack (or other integration) sender info
if (!props.sender) {
return {
name: t('CONVERSATION.BOT'),
src: '',
};
const { senderName, senderAvatarUrl } = props.additionalAttributes || {};
if (senderName) {
return { name: senderName, src: senderAvatarUrl ?? '' };
}
return { name: t('CONVERSATION.BOT'), src: '' };
}
const { sender } = props;
@@ -1,6 +1,6 @@
<script setup>
import { ref } from 'vue';
import RadioCard from '../RadioCard.vue';
import RadioCard from './RadioCard.vue';
const selectedOption = ref('round_robin');
+589 -119
View File
@@ -1,32 +1,69 @@
<script setup>
import { ref, provide, computed, watch, onMounted, defineEmits } from 'vue';
// [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 { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import {
useMapGetter,
useFunctionGetter,
} from 'dashboard/composables/store.js';
import { Virtualizer } from 'virtua/vue';
import ChatListHeader from './ChatListHeader.vue';
import ConversationList from './ConversationList.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 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 ChatListFilters from './ChatListFilters.vue';
import ConversationActiveFiltersPreview from './widgets/conversation/ConversationActiveFiltersPreview.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useI18n } from 'vue-i18n';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import { useEmitter } from 'dashboard/composables/emitter';
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 { useEmitter } from 'dashboard/composables/emitter';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
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';
const props = defineProps({
@@ -42,15 +79,34 @@ const props = defineProps({
const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const store = useStore();
const router = useRouter();
const route = useRoute();
const store = useStore();
const resolveAttributesModalRef = ref(null);
const conversationListRef = ref(null);
const virtualListRef = ref(null);
provide('contextMenuElementTarget', virtualListRef);
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 chatListFiltersRef = ref(null);
const appliedFilter = ref([]);
const advancedFilterTypes = ref(
advancedFilterOptions.map(filter => ({
...filter,
attributeName: t(`FILTER.ATTRIBUTES.${filter.attributeI18nKey}`),
}))
);
const currentUser = useMapGetter('getCurrentUser');
const chatLists = useMapGetter('getFilteredConversations');
@@ -61,10 +117,18 @@ const chatListLoading = useMapGetter('getChatListLoadingStatus');
const activeInbox = useMapGetter('getSelectedInbox');
const conversationStats = useMapGetter('conversationStats/getStats');
const appliedFilters = useMapGetter('getAppliedConversationFiltersV2');
const currentAccountId = useMapGetter('getCurrentAccountId');
const getTeamFn = useMapGetter('teams/getTeam');
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');
useChatListKeyboardEvents(conversationListRef);
const {
selectedConversations,
selectedInboxes,
@@ -73,28 +137,57 @@ const {
selectAllConversations,
resetBulkActions,
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions();
const {
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
// computed
const hasAppliedFilters = computed(() => {
return appliedFilters.value.length !== 0;
});
const hasActiveFolders = computed(() => {
return Boolean(props.foldersId !== 0);
});
const activeFolder = computed(() => {
return chatListFiltersRef.value?.activeFolder;
if (props.foldersId) {
const activeView = folders.value.filter(
view => view.id === Number(props.foldersId)
);
const [firstValue] = activeView;
return firstValue;
}
return undefined;
});
const isFilterModalOpen = computed(() => {
return chatListFiltersRef.value?.showAdvancedFilters ?? false;
const activeFolderName = computed(() => {
return activeFolder.value?.name;
});
const hasActiveFolders = computed(() => {
return Boolean(activeFolder.value && props.foldersId !== 0);
});
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);
});
@@ -138,6 +231,11 @@ const hasCurrentPageEndReached = useFunctionGetter(
currentPageFilterKey
);
const conversationCustomAttributes = useFunctionGetter(
'attributes/getAttributesByModel',
'conversation_attribute'
);
const activeAssigneeTabCount = computed(() => {
const count = assigneeTabItems.value.find(
item => item.key === activeAssigneeTab.value
@@ -207,7 +305,7 @@ const pageTitle = computed(() => {
if (props.conversationType === 'unattended') {
return t('CHAT_LIST.UNATTENDED_HEADING');
}
if (hasActiveFolders.value && activeFolder.value?.name) {
if (hasActiveFolders.value) {
return activeFolder.value.name;
}
return t('CHAT_LIST.TAB_HEADING');
@@ -229,7 +327,7 @@ const conversationList = computed(() => {
localConversationList = [...chatLists.value];
}
if (activeFolder.value?.query?.payload) {
if (activeFolder.value) {
const { payload } = activeFolder.value.query;
localConversationList = localConversationList.filter(conversation => {
return matchesFilters(conversation, payload);
@@ -240,7 +338,7 @@ const conversationList = computed(() => {
});
const showEndOfListMessage = computed(() => {
return !!(
return (
conversationList.value.length &&
hasCurrentPageEndReached.value &&
!chatListLoading.value
@@ -277,25 +375,176 @@ function emitConversationLoaded() {
}
function fetchFilteredConversations(payload) {
const transformedPayload = useSnakeCase(payload);
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: filterQueryGenerator(transformedPayload),
queryData: filterQueryGenerator(payload),
page,
})
.then(emitConversationLoaded);
showAdvancedFilters.value = false;
}
function fetchSavedFilteredConversations(payload) {
payload = useSnakeCase(payload);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: payload,
page,
})
.then(emitConversationLoaded);
}
function fetchSavedFilteredConversations(payload) {
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);
let page = currentFiltersPage.value + 1;
store
.dispatch('fetchFilteredConversations', {
queryData: transformedPayload,
page,
})
.then(emitConversationLoaded);
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() {
@@ -303,42 +552,13 @@ function fetchConversations() {
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 && activeFolder.value?.query) {
if (hasActiveFolders.value) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
}
@@ -355,7 +575,7 @@ function loadMoreConversations() {
if (!hasAppliedFiltersOrActiveFolders.value) {
fetchConversations();
} else if (hasActiveFolders.value && activeFolder.value?.query) {
} else if (hasActiveFolders.value) {
const payload = activeFolder.value.query;
fetchSavedFilteredConversations(payload);
} else if (hasAppliedFilters.value) {
@@ -363,6 +583,14 @@ function loadMoreConversations() {
}
}
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
// If the list doesn’t fill the viewport, loading can stall.
// IntersectionObserver triggers as soon as the sentinel is visible.
const intersectionObserverOptions = computed(() => ({
root: conversationListRef.value,
rootMargin: '100px 0px 100px 0px',
}));
function updateAssigneeTab(selectedTab) {
if (activeAssigneeTab.value !== selectedTab) {
resetBulkActions();
@@ -383,10 +611,174 @@ 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 store.getters.getConversationById(item)?.status === status;
return getConversationById.value(item)?.status === status;
});
}
@@ -414,19 +806,46 @@ 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()
@@ -435,10 +854,6 @@ watch(
computed(() => props.conversationType),
() => resetAndFetchData()
);
watch(
computed(() => props.foldersId),
() => resetAndFetchData()
);
watch(activeFolder, (newVal, oldVal) => {
if (newVal !== oldVal) {
@@ -460,7 +875,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative"
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -475,25 +890,32 @@ watch(conversationFilters, (newVal, oldVal) => {
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
:is-filter-modal-open="isFilterModalOpen"
@filters-modal="chatListFiltersRef?.onToggleAdvanceFiltersModal"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@basic-filter-change="onBasicFilterChange"
/>
<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"
<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"
/>
<ChatTypeTabs
@@ -503,45 +925,93 @@ watch(conversationFilters, (newVal, oldVal) => {
is-compact
@chat-tab-change="updateAssigneeTab"
/>
<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"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex overflow-auto justify-center items-center p-4"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<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"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
<div
ref="conversationListRef"
class="flex-1 min-h-0 overflow-y-auto conversations-list"
:class="{ '!overflow-hidden': isContextMenuOpen }"
>
{{ $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"
<Virtualizer
ref="virtualListRef"
v-slot="{ item, index }"
:data="conversationList"
>
<ConversationItem
:source="item"
:label="label"
:team-id="teamId"
:folders-id="foldersId"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:data-index="index"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
/>
</Virtualizer>
<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"
/>
</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"
/>
</div>
</template>
@@ -1,309 +0,0 @@
<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,10 +16,15 @@ 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(['basicFilterChange', 'filtersModal']);
const emit = defineEmits([
'addFolders',
'deleteFolders',
'resetFilters',
'basicFilterChange',
'filtersModal',
]);
const { uiSettings, updateUISettings } = useUISettings();
@@ -51,33 +56,61 @@ const toggleConversationLayout = () => {
</script>
<template>
<div class="flex items-center justify-between gap-2 px-4 h-[3.25rem]">
<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-center min-w-0">
<h1 class="text-heading-2 truncate text-n-slate-12" :title="pageTitle">
<h1
class="text-base font-medium 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-lg capitalize -outline-offset-1 outline outline-1 outline-n-container bg-n-button-color text-xxs text-n-slate-12 shrink-0"
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"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
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"
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"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-2">
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
:is-on-expanded-layout="isOnExpandedLayout"
@change-filter="onBasicFilterChange"
/>
<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>
<template v-if="hasActiveFolders">
<div class="relative">
<NextButton
@@ -85,8 +118,8 @@ const toggleConversationLayout = () => {
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
icon="i-lucide-pen-line"
slate
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
xs
faded
@click="emit('filtersModal')"
/>
<div
@@ -95,6 +128,15 @@ 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
@@ -102,8 +144,8 @@ const toggleConversationLayout = () => {
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
icon="i-lucide-list-filter"
slate
sm
:variant="isFilterModalOpen ? 'faded' : 'ghost'"
xs
faded
@click="emit('filtersModal')"
/>
<div
@@ -112,6 +154,11 @@ 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,175 +1,55 @@
<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,
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
components: {
ConversationCard,
},
teamId: {
type: [String, Number],
default: 0,
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,
},
},
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>
<ConversationCard
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
@@ -177,19 +57,18 @@ function handleDelete() {
: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="onAssignAgent"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="onAssignLabels"
@remove-label="onRemoveLabels"
@assign-label="assignLabels"
@remove-label="removeLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="handleDelete"
@delete-conversation="deleteConversation"
/>
</template>
@@ -1,265 +0,0 @@
<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,6 +1,5 @@
<script setup>
import { computed, inject } from 'vue';
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
const props = defineProps({
index: {
@@ -29,14 +28,11 @@ const props = defineProps({
},
});
const { formatCompactWithDecimal } = useNumberFormatter();
const activeIndex = inject('activeIndex');
const updateActiveIndex = inject('updateActiveIndex');
const active = computed(() => props.index === activeIndex.value);
const count = computed(() => props.count);
const getItemCount = computed(() => formatCompactWithDecimal(count.value));
const getItemCount = computed(() => props.count);
const onTabClick = event => {
event.preventDefault();
@@ -63,11 +59,7 @@ const onTabClick = event => {
{{ name }}
<div
v-if="showBadge"
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="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]"
:class="[
active
? 'bg-n-blue-3 text-n-blue-11'
@@ -24,6 +24,10 @@ export default {
type: [String, Date, Number],
default: '',
},
conversationId: {
type: [String, Number],
default: '',
},
},
data() {
return {
@@ -74,6 +78,15 @@ export default {
createdAtTimestamp() {
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
},
conversationId() {
// Reset display values and timer when the row is recycled to a different conversation.
this.lastActivityAtTimeAgo = dynamicTime(this.lastActivityTimestamp);
this.createdAtTimeAgo = dynamicTime(this.createdAtTimestamp);
if (this.isAutoRefreshEnabled) {
clearTimeout(this.timer);
this.createTimer();
}
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
@@ -111,7 +124,6 @@ export default {
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
hideOnClick: true,
}"
class="ml-auto leading-4 text-xxs text-n-slate-10 hover:text-n-slate-11"
>
@@ -48,13 +48,13 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-4 py-0 [&_ul]:p-0 h-10"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
@change="onTabChange"
>
<woot-tabs-item
v-for="(item, index) in items"
:key="item.key"
class="text-sm"
class="text-sm [&_a]:font-medium"
:index="index"
:name="item.name"
:count="item.count"
@@ -189,7 +189,7 @@ export default {
},
showAudioRecorderButton() {
if (this.isEditorDisabled) return false;
if (this.isALineChannel) {
if (this.isALineChannel || this.isATiktokChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
@@ -380,7 +380,11 @@ export default {
@click="$emit('selectContentTemplate')"
/>
<VideoCallButton
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
v-if="
(isAWebWidgetInbox || isAPIInbox) &&
!isOnPrivateNote &&
!isEditorDisabled
"
:conversation-id="conversationId"
/>
<transition name="modal-fade">
@@ -1,151 +0,0 @@
<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 { useTemplateRef, computed } from 'vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
@@ -26,7 +26,6 @@ const { updateUISettings } = useUISettings();
const chatStatusFilter = useMapGetter('getChatStatusFilter');
const chatSortFilter = useMapGetter('getChatSortFilter');
const filterContainerRef = useTemplateRef('filterContainerRef');
const [showActionsDropdown, toggleDropdown] = useToggle();
const currentStatusFilter = computed(() => {
@@ -87,6 +86,10 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_asc.TEXT'),
value: 'priority_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc_created_at_asc.TEXT'),
value: 'priority_desc_created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.waiting_since_asc.TEXT'),
value: 'waiting_since_asc',
@@ -132,21 +135,18 @@ const handleSortChange = value => {
</script>
<template>
<div ref="filterContainerRef" class="relative flex">
<div class="relative flex">
<NextButton
v-tooltip.right="$t('CHAT_LIST.SORT_TOOLTIP_LABEL')"
icon="i-lucide-arrow-up-down"
slate
sm
:variant="showActionsDropdown ? 'faded' : 'ghost'"
faded
xs
@click="toggleDropdown()"
/>
<div
v-if="showActionsDropdown"
v-on-click-outside="[
() => toggleDropdown(),
{ ignore: [filterContainerRef] },
]"
v-on-click-outside="() => toggleDropdown()"
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,
@@ -1,5 +1,5 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
@@ -50,10 +50,21 @@ const store = useStore();
const hovered = ref(false);
const showContextMenu = ref(false);
const contextMenu = ref({
x: null,
y: null,
});
const contextMenu = ref({ x: null, y: null });
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
// This prevents context menu/hover state from leaking to a different conversation
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
const resetState = () => {
if (showContextMenu.value) {
emit('contextMenuToggle', false);
}
hovered.value = false;
showContextMenu.value = false;
contextMenu.value = { x: null, y: null };
};
watch(() => props.chat.id, resetState);
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
@@ -243,7 +254,7 @@ const deleteConversation = () => {
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-2 rounded-lg': !compact,
'px-3': !compact,
}"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
@@ -352,6 +363,7 @@ const deleteConversation = () => {
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
:conversation-id="chat.id"
/>
</span>
<span
@@ -85,7 +85,12 @@ export default {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_SUCCESS'));
this.onCancel();
} catch (error) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
const status = error?.response?.status;
if (status === 402) {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_PAYMENT_REQUIRED'));
} else {
useAlert(this.$t('EMAIL_TRANSCRIPT.SEND_EMAIL_ERROR'));
}
} finally {
this.isSubmitting = false;
}
@@ -36,7 +36,6 @@ export default {
v-tooltip="{
content: tooltipText,
delay: { show: 1500, hide: 0 },
hideOnClick: true,
}"
class="shrink-0 rounded-sm inline-flex items-center justify-center w-3.5 h-3.5"
:class="{
@@ -200,9 +200,13 @@ export default {
},
messagePlaceHolder() {
if (this.isEditorDisabled) {
return this.isAWhatsAppChannel
? this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP')
: this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_WHATSAPP');
}
if (this.isAPIInbox) {
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED_API');
}
return this.$t('CONVERSATION.FOOTER.MESSAGING_RESTRICTED');
}
return this.isPrivate
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
@@ -279,7 +283,8 @@ export default {
this.isASmsInbox ||
this.isATelegramChannel ||
this.isALineChannel ||
this.isAnInstagramChannel
this.isAnInstagramChannel ||
this.isATiktokChannel
);
},
replyButtonLabel() {
@@ -426,7 +431,7 @@ export default {
},
isEditorDisabled() {
return (
this.isAWhatsAppChannel &&
(this.isAWhatsAppChannel || this.isAPIInbox) &&
!this.isOnPrivateNote &&
!this.currentChat.can_reply
);
@@ -751,11 +756,13 @@ export default {
this.isATwilioWhatsAppChannel ||
this.isAWhatsAppCloudChannel ||
this.is360DialogWhatsAppChannel;
// When users send messages containing both text and attachments on Instagram, Instagram treats them as separate messages.
// Although Chatwoot combines these into a single message, Instagram sends separate echo events for each component.
// This can create duplicate messages in Chatwoot. To prevent this issue, we'll handle text and attachments as separate messages.
// Instagram and TikTok do not support sending text and attachments in the same message.
// For Instagram, combining them causes duplicate messages due to separate echo events per component.
// For TikTok, the API rejects messages that mix text and media.
// To handle both cases, text and attachments are always sent as separate messages.
const isOnInstagram = this.isAnInstagramChannel;
if ((isOnWhatsApp || isOnInstagram) && !this.isPrivate) {
const isOnTiktok = this.isATiktokChannel;
if ((isOnWhatsApp || isOnInstagram || isOnTiktok) && !this.isPrivate) {
this.sendMessageAsMultipleMessages(
this.message,
copilotAcceptedMessage
@@ -1069,7 +1076,8 @@ export default {
const multipleMessagePayload = [];
if (this.attachedFiles && this.attachedFiles.length) {
let caption = this.isAnInstagramChannel ? '' : message;
let caption =
this.isAnInstagramChannel || this.isATiktokChannel ? '' : message;
this.attachedFiles.forEach(attachment => {
const attachedFile = this.globalConfig.directUploadsEnabled
? attachment.blobSignedId
@@ -1091,11 +1099,13 @@ export default {
const hasNoAttachments =
!this.attachedFiles || !this.attachedFiles.length;
// For Instagram, we need a separate text message
// For WhatsApp, we only need a text message if there are no attachments
// For Instagram and TikTok, text must always be sent as a separate message (no captions on attachments).
// For WhatsApp, we only need a text message if there are no attachments.
if (
(this.isAnInstagramChannel && this.message) ||
(!this.isAnInstagramChannel && hasNoAttachments)
((this.isAnInstagramChannel || this.isATiktokChannel) &&
this.message) ||
(!(this.isAnInstagramChannel || this.isATiktokChannel) &&
hasNoAttachments)
) {
let messagePayload = {
conversationId: this.currentChat.id,
@@ -0,0 +1,254 @@
<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>
@@ -1,197 +0,0 @@
<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>
@@ -1,153 +0,0 @@
<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>
@@ -1,164 +0,0 @@
<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>
@@ -1,109 +0,0 @@
<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,9 +1,7 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
<script>
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,
@@ -12,172 +10,313 @@ import {
} from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.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 AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
const props = defineProps({
conversations: {
type: Array,
default: () => [],
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
},
allConversationsSelected: {
type: Boolean,
default: false,
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,
},
},
selectedInboxes: {
type: Array,
default: () => [],
emits: [
'selectAllConversations',
'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
showOpenAction: {
type: Boolean,
default: false,
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
);
},
showResolvedAction: {
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
);
},
showSnoozedAction: {
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;
},
},
});
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>
<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"
>
<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"
<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)"
/>
</woot-modal>
<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>
</Transition>
<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"
>
<CustomSnoozeModal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</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>
@@ -0,0 +1,141 @@
<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>
@@ -0,0 +1,145 @@
<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>
@@ -0,0 +1,109 @@
<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,15 +23,9 @@ export function useBulkActions() {
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
// 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),
];
}
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
}
function resetBulkActions() {
@@ -21,6 +21,7 @@ export default {
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -39,7 +40,6 @@ 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',
-3
View File
@@ -21,10 +21,8 @@ export const FEATURE_FLAGS = {
AUDIT_LOGS: 'audit_logs',
INBOX_VIEW: 'inbox_view',
SLA: 'sla',
RESPONSE_BOT: 'response_bot',
CHANNEL_EMAIL: 'channel_email',
CHANNEL_FACEBOOK: 'channel_facebook',
CHANNEL_TWITTER: 'channel_twitter',
CHANNEL_WEBSITE: 'channel_website',
CUSTOM_REPLY_DOMAIN: 'custom_reply_domain',
CUSTOM_REPLY_EMAIL: 'custom_reply_email',
@@ -36,7 +34,6 @@ export const FEATURE_FLAGS = {
CAPTAIN: 'captain_integration',
CUSTOM_ROLES: 'custom_roles',
CHATWOOT_V4: 'chatwoot_v4',
REPORT_V4: 'report_v4',
CHANNEL_INSTAGRAM: 'channel_instagram',
CHANNEL_TIKTOK: 'channel_tiktok',
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
@@ -13,7 +13,6 @@ const FEATURE_HELP_URLS = {
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
@@ -21,6 +20,7 @@ const FEATURE_HELP_URLS = {
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
shopify: 'https://chwt.app/hc/shopify',
};
export function getHelpUrlForFeature(featureName) {
@@ -75,11 +75,6 @@
"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?",
@@ -100,9 +95,8 @@
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"SAVE_VIEW": "Save View",
"DELETE": {
"DELETE_BUTTON": "Delete",
"DELETE_BUTTON": "Delete filter",
"MODAL": {
"CONFIRM": {
"TITLE": "Confirm deletion",
@@ -1,14 +1,12 @@
{
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "{conversationCount} selected",
"CONVERSATIONS_SELECTED": "{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select agent",
"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?",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign {conversationCount} {conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign {conversationCount} {conversationLabel}?",
"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",
@@ -40,8 +38,6 @@
"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."
},
@@ -76,6 +76,9 @@
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
}
},
"ATTACHMENTS": {
@@ -71,8 +71,7 @@
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
"CARD": {
"SHOW_LABELS": "Show labels",
"HIDE_LABELS": "Hide labels",
"LABELS_COUNT": "{count} labels"
"HIDE_LABELS": "Hide labels"
},
"VOICE_CALL": {
"INCOMING_CALL": "Incoming call",
@@ -193,6 +192,7 @@
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGING_RESTRICTED": "You cannot reply to this conversation",
"MESSAGING_RESTRICTED_WHATSAPP": "You can only reply using a template message due to 24-hour message window restriction",
"MESSAGING_RESTRICTED_API": "You can only reply using a template message due to message window restriction",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
"COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
@@ -306,6 +306,7 @@
"CANCEL": "Cancel",
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
"SEND_EMAIL_ERROR": "There was an error, please try again",
"SEND_EMAIL_PAYMENT_REQUIRED": "Email transcript is not available on your current plan. Please upgrade to use this feature.",
"FORM": {
"SEND_TO_CONTACT": "Send the transcript to the customer",
"SEND_TO_AGENT": "Send the transcript to the assigned agent",
@@ -592,8 +592,10 @@
"DISABLED": "Disabled"
},
"LOCK_TO_SINGLE_CONVERSATION": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
"ENABLED": "Reopen same conversation",
"DISABLED": "Create new conversations",
"ENABLED_DESCRIPTION": "When a contact messages again, the previous conversation will be reopened.",
"DISABLED_DESCRIPTION": "A new conversation will be created each time after the previous one is resolved."
},
"ENABLE_HMAC": {
"LABEL": "Enable"
@@ -713,8 +715,8 @@
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
"LOCK_TO_SINGLE_CONVERSATION": "Lock to single conversation",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Enable or disable multiple conversations for the same contact in this inbox",
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",
"AUTO_ASSIGNMENT_SUB_TEXT": "Enable or disable the automatic assignment of new conversations to the agents added to this inbox.",
@@ -11,9 +11,18 @@
"LABEL": "Store URL",
"PLACEHOLDER": "your-store.myshopify.com",
"HELP": "Enter your Shopify store's myshopify.com URL",
"INVALID_URL": "Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)",
"CANCEL": "Cancel",
"SUBMIT": "Connect Store"
},
"PENDING_INSTALL": {
"SUCCESS": "Shopify integration connected successfully.",
"ERROR": "Failed to complete Shopify installation. The link may have expired."
},
"HELP_TEXT": {
"TITLE": "How to use the Shopify Integration?",
"BODY": "With this integration, your Shopify store ***{storeDomain}*** is connected to your Chatwoot workspace. Here's what you can do:\n\n**Track orders in conversations:** When you open a conversation, the Shopify sidebar will automatically display recent orders for the customer based on their email address. This gives your support team instant context without switching tabs.\n\n**Access order details:** View order status, fulfillment status, total amount, and individual line items directly within the conversation panel."
},
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
},
"HEADER": "Integrations",
@@ -31,6 +40,14 @@
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"LEARN_MORE": "Learn more about webhooks",
"SECRET": {
"LABEL": "Secret",
"COPY": "Copy secret to clipboard",
"COPY_SUCCESS": "Secret copied to clipboard",
"TOGGLE": "Toggle secret visibility",
"CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.",
"DONE": "Done"
},
"COUNT": "{n} webhook | {n} webhooks",
"SEARCH_PLACEHOLDER": "Search webhooks...",
"NO_RESULTS": "No webhooks found matching your search",
@@ -119,10 +119,7 @@ const debouncedSearch = debounce(async query => {
}
try {
const contacts = await searchContacts({
keys: ['name', 'email', 'phone_number'],
query,
});
const contacts = await searchContacts(query);
// Add selected contact to top if not already in results
const allContacts = selectedContact.value
@@ -1,10 +1,11 @@
<script setup>
import { computed } from 'vue';
import { computed, ref } 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 BulkLabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/BulkLabelActions.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
@@ -33,6 +34,7 @@ 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) {
@@ -71,11 +73,22 @@ 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>
@@ -94,33 +107,56 @@ const handleAssignLabels = labels => {
<Button
sm
ghost
slate
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
class="!px-1"
class="!px-1.5"
@click="emitClearSelection"
/>
</template>
<template #actions>
<div class="flex items-center gap-2 ml-auto">
<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" />
<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>
<Policy :permissions="['administrator']">
<Button
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
sm
ghost
faded
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-2 [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
class="!px-1.5 [&>span:nth-child(2)]:hidden"
@click="emit('deleteSelected')"
/>
</Policy>
@@ -1,12 +1,9 @@
<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';
<script>
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import ChatList from 'dashboard/components/ChatList.vue';
import ConversationBox from 'dashboard/components/widgets/conversation/ConversationBox.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import ChatList from '../../../components/ChatList.vue';
import ConversationBox from '../../../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';
@@ -14,144 +11,187 @@ import { emitter } from 'shared/helpers/mitt';
import SidepanelSwitch from 'dashboard/components-next/Conversation/SidepanelSwitch.vue';
import ConversationSidebar from 'dashboard/components/widgets/conversation/ConversationSidebar.vue';
const props = defineProps({
inboxId: {
type: [String, Number],
default: 0,
export default {
components: {
ChatList,
ConversationBox,
CmdBarConversationSnooze,
SidepanelSwitch,
ConversationSidebar,
},
conversationId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
teamId: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
});
const route = useRoute();
const store = useStore();
const { uiSettings } = useUISettings();
const chatList = useMapGetter('getAllConversations');
const currentChat = useMapGetter('getSelectedChat');
const isOnExpandedLayout = computed(() => {
const {
LAYOUT_TYPES: { CONDENSED },
} = wootConstants;
const { conversation_display_type: conversationDisplayType = CONDENSED } =
uiSettings.value;
return conversationDisplayType !== CONDENSED;
});
const showConversationList = computed(() =>
isOnExpandedLayout.value ? !props.conversationId : true
);
const showMessageView = computed(() =>
props.conversationId ? true : !isOnExpandedLayout.value
);
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;
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');
}
const { messageId } = route.query;
store
.dispatch('setActiveChat', {
data: selectedConversation,
after: messageId,
})
.then(() => {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
next(); // Continue with navigation
},
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,
},
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
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;
},
shouldShowSidebar() {
if (!this.currentChat.id) {
return false;
}
const { is_contact_sidebar_open: isContactSidebarOpen } = this.uiSettings;
return isContactSidebarOpen;
},
},
watch: {
conversationId() {
this.fetchConversationIfUnavailable();
},
},
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');
}
},
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();
});
},
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,
});
} else {
store.dispatch('clearSelectedState');
}
},
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 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>
@@ -23,15 +23,15 @@ export default {
<template>
<NextButton
v-tooltip.left="$t('CONVERSATION.SWITCH_VIEW_LAYOUT')"
icon="i-woot-expand-list"
:icon="
isOnExpandedLayout
? 'i-lucide-arrow-left-to-line'
: 'i-lucide-arrow-right-to-line'
"
slate
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,
}"
xs
faded
class="flex-shrink-0 rtl:rotate-180 ltr:rotate-0 md:inline-flex hidden"
@click="toggle"
/>
</template>
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import BaseInfo from 'dashboard/components-next/AssignmentPolicy/components/BaseInfo.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
@@ -45,7 +45,10 @@ const records = computed(() =>
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['short_code', 'content']);
return picoSearch(records.value, query, [
{ name: 'short_code', weight: 4 },
'content',
]);
});
const uiFlags = computed(() => getters.getUIFlags.value);
@@ -27,6 +27,7 @@ import BotConfiguration from './components/BotConfiguration.vue';
import AccountHealth from './components/AccountHealth.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue';
import LockToSingleConversationPreview from './components/LockToSingleConversationPreview.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SpinnerLoader from 'dashboard/components-next/spinner/Spinner.vue';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -53,6 +54,7 @@ export default {
SettingsAccordion,
WeeklyAvailability,
SenderNameExamplePreview,
LockToSingleConversationPreview,
MicrosoftReauthorize,
GoogleReauthorize,
NextButton,
@@ -246,6 +248,9 @@ export default {
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAPIInbox ||
this.isAnInstagramChannel ||
this.isALineChannel ||
this.isATiktokChannel ||
this.isATelegramChannel
);
},
@@ -536,6 +541,9 @@ export default {
hideBusinessNameInput() {
this.showBusinessNameInput = false;
},
toggleLockToSingleConversation(value) {
this.locktoSingleConversation = value;
},
},
validations: {
webhookUrl: {
@@ -731,6 +739,21 @@ export default {
/>
</SettingsFieldSection>
<SettingsFieldSection
v-if="canLocktoSingleConversation"
:label="
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
"
class="[&>div>div]:justify-end [&>div>div]:flex lg:[&>div:first-child]:h-12 [&>div:first-child]:h-16"
>
<template #extra>
<LockToSingleConversationPreview
:lock-to-single-conversation="locktoSingleConversation"
@update="toggleLockToSingleConversation"
/>
</template>
</SettingsFieldSection>
<SettingsFieldSection
v-if="isAWebWidgetInbox || isAnEmailChannel"
:label="$t('INBOX_MGMT.EDIT.SENDER_NAME_SECTION.TITLE')"
@@ -1074,19 +1097,6 @@ export default {
)
"
/>
<SettingsToggleSection
v-if="canLocktoSingleConversation"
v-model="locktoSingleConversation"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT'
)
"
/>
</SettingsAccordion>
<div class="w-full flex justify-end items-center py-4 mt-2">
@@ -0,0 +1,40 @@
<script setup>
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
defineProps({
lockToSingleConversation: {
type: Boolean,
default: false,
},
});
defineEmits(['update']);
</script>
<template>
<div
class="flex flex-col sm:flex-row md:flex-col xl:flex-row items-start gap-4 mt-3 min-w-0"
>
<RadioCard
id="disabled"
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED')"
:description="
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.DISABLED_DESCRIPTION')
"
:is-active="!lockToSingleConversation"
class="flex-1"
@select="$emit('update', false)"
/>
<RadioCard
id="enabled"
:label="$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED')"
:description="
$t('INBOX_MGMT.EDIT.LOCK_TO_SINGLE_CONVERSATION.ENABLED_DESCRIPTION')
"
:is-active="lockToSingleConversation"
class="flex-1"
@select="$emit('update', true)"
/>
</div>
</template>
@@ -2,7 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
import RadioCard from 'dashboard/components-next/AssignmentPolicy/components/RadioCard.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
const props = defineProps({
senderNameType: {
@@ -1,16 +1,17 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Integration from './Integration.vue';
import integrationAPI from 'dashboard/api/integrations';
import shopifyAPI from 'dashboard/api/integrations/shopify';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
@@ -23,12 +24,11 @@ defineProps({
});
const store = useStore();
const { t } = useI18n();
const dialogRef = ref(null);
const integrationLoaded = ref(false);
const storeUrl = ref('');
const isSubmitting = ref(false);
const storeUrlError = ref('');
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const integration = useFunctionGetter('integrations/getIntegration', 'shopify');
const uiFlags = useMapGetter('integrations/getUIFlags');
@@ -39,50 +39,43 @@ const integrationAction = computed(() => {
return 'connect';
});
const hideStoreUrlModal = () => {
storeUrl.value = '';
storeUrlError.value = '';
isSubmitting.value = false;
};
const hook = computed(() => {
const { hooks = [] } = integration.value || {};
const [firstHook] = hooks;
return firstHook || {};
});
const validateStoreUrl = url => {
const pattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*\.myshopify\.com$/;
return pattern.test(url);
};
const storeDomain = computed(() => hook.value.reference_id || '');
const openStoreUrlDialog = () => {
if (dialogRef.value) {
dialogRef.value.open();
}
};
const formattedHelpText = computed(() => {
return formatMessage(
t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.BODY', {
storeDomain: storeDomain.value,
}),
false
);
});
const handleStoreUrlSubmit = async () => {
const completePendingInstall = async token => {
try {
storeUrlError.value = '';
if (!validateStoreUrl(storeUrl.value)) {
storeUrlError.value =
'Please enter a valid Shopify store URL (e.g., your-store.myshopify.com)';
return;
}
isSubmitting.value = true;
const { data } = await integrationAPI.connectShopify({
shopDomain: storeUrl.value,
});
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} catch (error) {
storeUrlError.value = error.message;
await shopifyAPI.completeInstall(token);
await store.dispatch('integrations/get', 'shopify');
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.SUCCESS'));
} catch {
useAlert(t('INTEGRATION_SETTINGS.SHOPIFY.PENDING_INSTALL.ERROR'));
} finally {
isSubmitting.value = false;
router.replace({ query: {} });
}
};
const initializeShopifyIntegration = async () => {
await store.dispatch('integrations/get', 'shopify');
integrationLoaded.value = true;
const pendingInstallToken = route.query.shopify_pending_install;
if (pendingInstallToken) {
await completePendingInstall(pendingInstallToken);
}
};
onMounted(() => {
@@ -122,35 +115,27 @@ onMounted(() => {
/>
</template>
</Integration>
<div
v-if="integration.enabled"
class="flex-1 w-full px-6 py-5 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
>
<div class="max-w-5xl prose-lg">
<h5 class="tracking-tight text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.SHOPIFY.HELP_TEXT.TITLE') }}
</h5>
<div v-dompurify-html="formattedHelpText" class="text-n-slate-11" />
</div>
</div>
<div
v-if="error"
class="flex items-center justify-center flex-1 outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-6"
class="flex items-center justify-center flex-1 p-6 rounded-md shadow outline outline-n-container outline-1 bg-n-alpha-3"
>
<p class="text-n-ruby-9">
{{ t('INTEGRATION_SETTINGS.SHOPIFY.ERROR') }}
</p>
</div>
<Dialog
ref="dialogRef"
:title="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.TITLE')"
:is-loading="isSubmitting"
@confirm="handleStoreUrlSubmit"
@close="hideStoreUrlModal"
>
<Input
v-model="storeUrl"
:label="t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.LABEL')"
:placeholder="
t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.PLACEHOLDER')
"
:message="
!storeUrlError
? t('INTEGRATION_SETTINGS.SHOPIFY.STORE_URL.HELP')
: storeUrlError
"
:message-type="storeUrlError ? 'error' : 'info'"
/>
</Dialog>
</div>
</template>
</SettingsLayout>
@@ -58,6 +58,7 @@ export default {
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {
@@ -1,60 +1,98 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import WebhookForm from './WebhookForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { WebhookForm },
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
},
const props = defineProps({
onClose: {
type: Function,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
const createdWebhook = ref(null);
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
const onSubmit = async webhook => {
try {
const result = await store.dispatch('webhooks/create', { webhook });
createdWebhook.value = result;
} catch (error) {
const message =
error.response.data.message ||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
};
const handleCopySecret = async () => {
await copyTextToClipboard(createdWebhook.value.secret);
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
};
</script>
<template>
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
<template v-if="createdWebhook">
<woot-modal-header
:header-title="
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
"
/>
<div class="px-8 pb-6">
<p class="text-sm text-n-slate-11 mb-4">
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
</p>
<label>
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="createdWebhook.secret"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
icon="i-lucide-copy"
slate
faded
@click="handleCopySecret"
/>
</div>
</label>
<div class="flex justify-end mt-4">
<NextButton
blue
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
@click="props.onClose()"
/>
</div>
</div>
</template>
<template v-else>
<woot-modal-header
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="props.onClose()"
/>
</template>
</div>
</template>
@@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core';
import { required, url, minLength } from '@vuelidate/validators';
import wootConstants from 'dashboard/constants/globals';
import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
@@ -57,10 +59,14 @@ export default {
url: this.value.url || '',
name: this.value.name || '',
subscriptions: this.value.subscriptions || [],
secretVisible: false,
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
computed: {
hasSecret() {
return !!this.value.secret;
},
webhookURLInputPlaceholder() {
return this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER',
@@ -81,6 +87,10 @@ export default {
subscriptions: this.subscriptions,
});
},
async copySecret() {
await copyTextToClipboard(this.value.secret);
useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
},
getI18nKey,
},
};
@@ -111,6 +121,35 @@ export default {
:placeholder="webhookNameInputPlaceholder"
/>
</label>
<label v-if="hasSecret" class="mb-4">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
type="button"
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
slate
faded
@click="secretVisible = !secretVisible"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
type="button"
icon="i-lucide-copy"
slate
faded
@click="copySecret"
/>
</div>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>
@@ -32,7 +32,10 @@ const records = computed(() => getters['labels/getLabels'].value);
const filteredRecords = computed(() => {
const query = searchQuery.value.trim();
if (!query) return records.value;
return picoSearch(records.value, query, ['title', 'description']);
return picoSearch(records.value, query, [
{ name: 'title', weight: 4 },
'description',
]);
});
const uiFlags = computed(() => getters['labels/getUIFlags'].value);
+4
View File
@@ -28,6 +28,10 @@ export const validateAuthenticateRoutePermission = (to, next) => {
}
if (to.name === 'no_accounts' || !to.name) {
const { redirect_url: redirectUrl } = to.query || {};
if (redirectUrl) {
return next(frontendURL(`accounts/${accountId}/${redirectUrl}`));
}
return next(frontendURL(`accounts/${accountId}/dashboard`));
}
@@ -457,11 +457,7 @@ const actions = {
},
sendEmailTranscript: async (_, { conversationId, email }) => {
try {
await ConversationApi.sendEmailTranscript({ conversationId, email });
} catch (error) {
throw new Error(error);
}
await ConversationApi.sendEmailTranscript({ conversationId, email });
},
updateCustomAttributes: async (
@@ -116,6 +116,7 @@ const SORT_OPTIONS = {
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
priority_desc_created_at_asc: ['sortOnPriorityCreatedAt', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
@@ -139,6 +140,14 @@ const sortConfig = {
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnPriorityCreatedAt: (a, b) => {
const DEFAULT_FOR_NULL = 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
if (p1 !== p2) return p2 - p1;
return a.created_at - b.created_at;
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
@@ -42,6 +42,7 @@ export const actions = {
} = response.data;
commit(types.default.ADD_WEBHOOK, webhook);
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
return webhook;
} catch (error) {
commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false });
throw error;
@@ -46,9 +46,7 @@ export default {
filteredActiveLabels() {
if (!this.search) return this.accountLabels;
return picoSearch(this.accountLabels, this.search, ['title'], {
threshold: 0.9,
});
return picoSearch(this.accountLabels, this.search, ['title']);
},
noResult() {
@@ -361,77 +361,4 @@ 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,37 +60,8 @@ 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,
};
}
+2
View File
@@ -12,6 +12,7 @@ import {
export const login = async ({
ssoAccountId,
ssoConversationId,
redirectUrl,
...credentials
}) => {
try {
@@ -31,6 +32,7 @@ export const login = async ({
window.location = getLoginRedirectURL({
ssoAccountId,
ssoConversationId,
redirectUrl,
user: response.data.data,
});
return null;
+8
View File
@@ -40,8 +40,16 @@ export const getCredentialsFromEmail = email => {
export const getLoginRedirectURL = ({
ssoAccountId,
ssoConversationId,
redirectUrl,
user,
}) => {
if (redirectUrl) {
const { accounts = [], account_id = null } = user || {};
const accountId = account_id || accounts[0]?.id;
if (accountId) {
return frontendURL(`accounts/${accountId}/${redirectUrl}`);
}
}
const accountPath = getSSOAccountPath({ ssoAccountId, user });
if (accountPath) {
if (ssoConversationId) {
+5 -1
View File
@@ -29,7 +29,11 @@ export const validateRouteAccess = (to, next, chatwootConfig = {}) => {
// Redirect to dashboard if a cookie is present, the cookie
// cleanup and token validation happens in the application pack.
if (hasAuthCookie()) {
replaceRouteWithReload(DEFAULT_REDIRECT_URL);
const { redirect_url: redirectUrl } = to.query || {};
const redirectTarget = redirectUrl
? `${DEFAULT_REDIRECT_URL}?redirect_url=${encodeURIComponent(redirectUrl)}`
: DEFAULT_REDIRECT_URL;
replaceRouteWithReload(redirectTarget);
return;
}
+2
View File
@@ -43,6 +43,7 @@ export default {
ssoConversationId: { type: String, default: '' },
email: { type: String, default: '' },
authError: { type: String, default: '' },
redirectUrl: { type: String, default: '' },
},
setup() {
const { replaceInstallationName } = useBranding();
@@ -169,6 +170,7 @@ export default {
sso_auth_token: this.ssoAuthToken,
ssoAccountId: this.ssoAccountId,
ssoConversationId: this.ssoConversationId,
redirectUrl: this.redirectUrl,
};
login(credentials)
+1
View File
@@ -19,6 +19,7 @@ export default [
ssoAccountId: route.query.sso_account_id,
ssoConversationId: route.query.sso_conversation_id,
authError: route.query.error,
redirectUrl: route.query.redirect_url,
}),
},
{
@@ -72,6 +72,10 @@ export default {
return this.message.sender.available_name || this.message.sender.name;
}
if (this.message.additional_attributes?.sender_name) {
return this.message.additional_attributes.sender_name;
}
if (this.useInboxAvatarForBot) {
return this.channelConfig.websiteName;
}
@@ -87,9 +91,13 @@ export default {
return displayImage;
}
return this.message.sender
? this.message.sender.avatar_url
: displayImage;
if (this.message.sender) {
return this.message.sender.avatar_url;
}
return (
this.message.additional_attributes?.sender_avatar_url || displayImage
);
},
hasRecordedResponse() {
return (
+2 -2
View File
@@ -1,7 +1,7 @@
class WebhookJob < ApplicationJob
queue_as :medium
# There are 3 types of webhooks, account, inbox and agent_bot
def perform(url, payload, webhook_type = :account_webhook)
Webhooks::Trigger.execute(url, payload, webhook_type)
def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil)
Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id)
end
end
+5 -2
View File
@@ -111,7 +111,9 @@ class WebhookListener < BaseListener
account.webhooks.account_type.each do |webhook|
next unless webhook.subscriptions.include?(payload[:event])
WebhookJob.perform_later(webhook.url, payload)
WebhookJob.perform_later(webhook.url, payload, :account_webhook,
secret: webhook.secret,
delivery_id: SecureRandom.uuid)
end
end
@@ -119,7 +121,8 @@ class WebhookListener < BaseListener
return unless inbox.channel_type == 'Channel::Api'
return if inbox.channel.webhook_url.blank?
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook)
WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook,
delivery_id: SecureRandom.uuid)
end
def deliver_webhook_payloads(payload, inbox)
+2
View File
@@ -41,6 +41,7 @@ class Account < ApplicationRecord
'audio_transcriptions': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] },
'keep_pending_on_bot_failure': { 'type': %w[boolean null] },
'captain_disable_auto_resolve': { 'type': %w[boolean null] },
'conversation_required_attributes': {
'type': %w[array null],
'items': { 'type': 'string' }
@@ -90,6 +91,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :captain_disable_auto_resolve
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async

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