Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38967fe75f | ||
|
|
7ade9061a8 | ||
|
|
9eb3ee44a8 | ||
|
|
ef6ba8aabd | ||
|
|
6e80f90357 | ||
|
|
f0e9ffacd3 | ||
|
|
45a1a01a96 | ||
|
|
c884cdefde | ||
|
|
c77d935e38 | ||
|
|
b686d14044 | ||
|
|
133fb1bcf6 | ||
|
|
e9e6de5690 | ||
|
|
329b749702 | ||
|
|
d8c5dda36c | ||
|
|
5ec77aca64 | ||
|
|
85324c82fa | ||
|
|
81307d5aea | ||
|
|
0e225d2f36 | ||
|
|
6f45af605c | ||
|
|
f45e82016d | ||
|
|
137f0e726b | ||
|
|
a32565d72b | ||
|
|
9238b4cc92 | ||
|
|
acd0f1457e | ||
|
|
77493c5d0f | ||
|
|
0d9c0b2ed2 | ||
|
|
2a69b37958 | ||
|
|
0ca98bc84f | ||
|
|
6cd1b37981 | ||
|
|
3b612e2b20 | ||
|
|
d166ae73bc | ||
|
|
aaeea6c9bf | ||
|
|
b870a48734 | ||
|
|
68f0da7351 | ||
|
|
40c622ed95 | ||
|
|
7cddba2b08 |
@@ -144,7 +144,7 @@ jobs:
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
<<: *defaults
|
||||
parallelism: 16
|
||||
parallelism: 20
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
|
||||
@@ -276,3 +276,4 @@ AZURE_APP_SECRET=
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
# REDIS_VELMA_SIZE=10
|
||||
|
||||
@@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
return if story_reply_attributes.blank?
|
||||
|
||||
@message.save_story_info(story_reply_attributes)
|
||||
create_story_reply_attachment(story_reply_attributes['url'])
|
||||
end
|
||||
|
||||
def create_story_reply_attachment(story_url)
|
||||
return if story_url.blank?
|
||||
|
||||
attachment = @message.attachments.new(
|
||||
file_type: :ig_story,
|
||||
account_id: @message.account_id,
|
||||
external_url: story_url
|
||||
)
|
||||
attachment.save!
|
||||
begin
|
||||
attach_file(attachment, story_url)
|
||||
rescue Down::Error, StandardError => e
|
||||
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
|
||||
end
|
||||
@message.content_attributes[:image_type] = 'ig_story_reply'
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def build_conversation
|
||||
@@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
message_type: message_type,
|
||||
status: @outgoing_echo ? :delivered : :sent,
|
||||
source_id: message_identifier,
|
||||
content: message_content,
|
||||
sender: @outgoing_echo ? nil : contact,
|
||||
@@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
||||
}
|
||||
}
|
||||
|
||||
params[:content_attributes][:external_echo] = true if @outgoing_echo
|
||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||
params
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class V2::Reports::FirstResponseTimeDistributionBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
build_distribution
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_distribution
|
||||
results = fetch_aggregated_counts
|
||||
map_to_channel_types(results)
|
||||
end
|
||||
|
||||
def fetch_aggregated_counts
|
||||
ReportingEvent
|
||||
.where(account_id: account.id, name: 'first_response')
|
||||
.where(range_condition)
|
||||
.group(:inbox_id)
|
||||
.select(
|
||||
:inbox_id,
|
||||
bucket_case_statements
|
||||
)
|
||||
end
|
||||
|
||||
def bucket_case_statements
|
||||
<<~SQL.squish
|
||||
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
|
||||
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
|
||||
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
|
||||
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
|
||||
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
|
||||
SQL
|
||||
end
|
||||
|
||||
def range_condition
|
||||
range.present? ? { created_at: range } : {}
|
||||
end
|
||||
|
||||
def inbox_channel_types
|
||||
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
|
||||
end
|
||||
|
||||
def map_to_channel_types(results)
|
||||
results.each_with_object({}) do |row, hash|
|
||||
channel_type = inbox_channel_types[row.inbox_id]
|
||||
next unless channel_type
|
||||
|
||||
hash[channel_type] ||= empty_buckets
|
||||
hash[channel_type]['0-1h'] += row.bucket_0_1h
|
||||
hash[channel_type]['1-4h'] += row.bucket_1_4h
|
||||
hash[channel_type]['4-8h'] += row.bucket_4_8h
|
||||
hash[channel_type]['8-24h'] += row.bucket_8_24h
|
||||
hash[channel_type]['24h+'] += row.bucket_24h_plus
|
||||
end
|
||||
end
|
||||
|
||||
def empty_buckets
|
||||
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
class V2::Reports::InboxLabelMatrixBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
def initialize(account:, params:)
|
||||
@account = account
|
||||
@params = params
|
||||
end
|
||||
|
||||
def build
|
||||
{
|
||||
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||
matrix: build_matrix
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def filtered_inboxes
|
||||
@filtered_inboxes ||= begin
|
||||
inboxes = account.inboxes
|
||||
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||
inboxes.order(:name).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def filtered_labels
|
||||
@filtered_labels ||= begin
|
||||
labels = account.labels
|
||||
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||
labels.order(:title).to_a
|
||||
end
|
||||
end
|
||||
|
||||
def conversation_filter
|
||||
filter = { account_id: account.id }
|
||||
filter[:created_at] = range if range.present?
|
||||
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||
filter
|
||||
end
|
||||
|
||||
def fetch_grouped_counts
|
||||
label_names = filtered_labels.map(&:title)
|
||||
return {} if label_names.empty?
|
||||
|
||||
ActsAsTaggableOn::Tagging
|
||||
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||
.where(tags: { name: label_names })
|
||||
.group('conversations.inbox_id', 'tags.name')
|
||||
.count
|
||||
end
|
||||
|
||||
def build_matrix
|
||||
counts = fetch_grouped_counts
|
||||
filtered_inboxes.map do |inbox|
|
||||
filtered_labels.map do |label|
|
||||
counts[[inbox.id, label.title]] || 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -28,8 +28,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
@contacts_count = @contacts.total_count
|
||||
@contacts = fetch_contacts_with_has_more(contacts)
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -142,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
.per(RESULTS_PER_PAGE)
|
||||
end
|
||||
|
||||
def fetch_contacts_with_has_more(contacts)
|
||||
includes_hash = { avatar_attachment: [:blob] }
|
||||
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
|
||||
|
||||
# Calculate offset manually to fetch one extra record for has_more check
|
||||
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
|
||||
results = filtrate(contacts)
|
||||
.includes(includes_hash)
|
||||
.offset(offset)
|
||||
.limit(RESULTS_PER_PAGE + 1)
|
||||
.to_a
|
||||
|
||||
@has_more = results.size > RESULTS_PER_PAGE
|
||||
results = results.first(RESULTS_PER_PAGE) if @has_more
|
||||
@contacts_count = results.size
|
||||
results
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:inbox_id].blank?
|
||||
|
||||
|
||||
@@ -70,8 +70,10 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def transcript
|
||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||
return head :too_many_requests unless @conversation.account.within_email_rate_limit?
|
||||
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||
@conversation.account.increment_email_sent_count
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -35,12 +35,9 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def transcript
|
||||
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
conversation.contact.email
|
||||
)&.deliver_later
|
||||
end
|
||||
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
|
||||
|
||||
send_transcript_email
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -77,6 +74,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
|
||||
private
|
||||
|
||||
def send_transcript_email
|
||||
return if conversation.contact&.email.blank?
|
||||
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
conversation.contact.email
|
||||
)&.deliver_later
|
||||
conversation.account.increment_email_sent_count
|
||||
end
|
||||
|
||||
def trigger_typing_event(event)
|
||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
||||
end
|
||||
|
||||
@@ -62,6 +62,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render json: bot_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix
|
||||
builder = V2::Reports::InboxLabelMatrixBuilder.new(
|
||||
account: Current.account,
|
||||
params: inbox_label_matrix_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
def first_response_time_distribution
|
||||
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
|
||||
account: Current.account,
|
||||
params: first_response_time_distribution_params
|
||||
)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_csv(filename, template)
|
||||
@@ -139,4 +155,20 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
def conversation_metrics
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
|
||||
def inbox_label_matrix_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
inbox_ids: params[:inbox_ids],
|
||||
label_ids: params[:label_ids]
|
||||
}
|
||||
end
|
||||
|
||||
def first_response_time_distribution_params
|
||||
{
|
||||
since: params[:since],
|
||||
until: params[:until]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Inherits from ActionController::Base to skip all middleware,
|
||||
# authentication, and callbacks. Used for health checks
|
||||
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||
def show
|
||||
render json: { status: 'woot' }
|
||||
end
|
||||
end
|
||||
@@ -42,11 +42,11 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'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],
|
||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
||||
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
|
||||
@@ -55,7 +55,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
|
||||
@allowed_configs = mapping.fetch(
|
||||
@config,
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
|
||||
@@ -37,14 +37,17 @@ class TasksAPI extends ApiClient {
|
||||
/**
|
||||
* Summarizes a conversation.
|
||||
* @param {string} conversationId - The conversation ID to summarize.
|
||||
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||
* @param {Object} [options] - Additional options.
|
||||
* @param {boolean} [options.forceRegenerate] - Force regeneration of cached summary.
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
|
||||
* @returns {Promise} A promise that resolves with the summary.
|
||||
*/
|
||||
summarize(conversationId, signal) {
|
||||
summarize(conversationId, { forceRegenerate = false, signal } = {}) {
|
||||
return axios.post(
|
||||
`${this.url}/summarize`,
|
||||
{
|
||||
conversation_display_id: conversationId,
|
||||
force_regenerate: forceRegenerate,
|
||||
},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
@@ -107,21 +107,39 @@
|
||||
--violet-11: 101 85 183;
|
||||
--violet-12: 47 38 95;
|
||||
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--background-color: 247 247 247;
|
||||
--surface-1: 254 254 254;
|
||||
--surface-2: 255 255 255;
|
||||
--surface-active: 255 255 255;
|
||||
--background-input-box: 0, 0, 0, 0.03;
|
||||
--text-blue: 1 22 44;
|
||||
--text-purple: 2 4 49;
|
||||
--text-amber: 37 24 1;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 235 235 235;
|
||||
--border-strong: 226 227 231;
|
||||
--border-weak: 234 234 234;
|
||||
--border-blue-strong: 18 61 117;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-amber: 255 228 181;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-blue-2: 251 253 255;
|
||||
--solid-iris: 230 231 255;
|
||||
--solid-purple: 230 231 255;
|
||||
--solid-red: 254 200 201;
|
||||
--solid-amber-button: 255 221 141;
|
||||
--card-color: 255 255 255;
|
||||
--overlay: 0, 0, 0, 0.12;
|
||||
--overlay-avatar: 255, 255, 255, 0.67;
|
||||
--button-color: 255 255 255;
|
||||
--button-hover-color: 255, 255, 255, 0.2;
|
||||
--label-background: 247 247 247;
|
||||
--label-border: 0, 0, 0, 0.04;
|
||||
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-1: 215, 215, 215, 0.22;
|
||||
--alpha-2: 196, 197, 198, 0.22;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
@@ -235,25 +253,43 @@
|
||||
--violet-11: 169 153 236;
|
||||
--violet-12: 226 221 254;
|
||||
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--background-color: 28 29 32;
|
||||
--surface-1: 20 21 23;
|
||||
--surface-2: 22 23 26;
|
||||
--surface-active: 53 57 66;
|
||||
--background-input-box: 255, 255, 255, 0.02;
|
||||
--text-blue: 213 234 255;
|
||||
--text-purple: 232 233 254;
|
||||
--text-amber: 255 247 234;
|
||||
--border-strong: 46 45 50;
|
||||
--border-weak: 31 31 37;
|
||||
--border-blue-strong: 201 226 255;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-amber: 56 50 41;
|
||||
--solid-blue: 15 57 102;
|
||||
--solid-blue-2: 26 29 35;
|
||||
--solid-iris: 38 42 101;
|
||||
--text-blue: 126 182 255;
|
||||
--solid-purple: 51 51 107;
|
||||
--solid-red: 90 33 34;
|
||||
--solid-amber-button: 255 221 141;
|
||||
--card-color: 28 30 34;
|
||||
--overlay: 0, 0, 0, 0.4;
|
||||
--overlay-avatar: 0, 0, 0, 0.05;
|
||||
--button-color: 42 43 51;
|
||||
--button-hover-color: 0, 0, 0, 0.15;
|
||||
--label-background: 36 38 45;
|
||||
--label-border: 255, 255, 255, 0.03;
|
||||
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--alpha-1: 35, 36, 42, 0.8;
|
||||
--alpha-2: 147, 153, 176, 0.12;
|
||||
--alpha-3: 33, 34, 38, 0.95;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@apply bg-n-slate-3;
|
||||
|
||||
&::after {
|
||||
@apply text-n-blue-text;
|
||||
@apply text-n-blue-11;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -119,7 +119,7 @@ onMounted(() => {
|
||||
)
|
||||
"
|
||||
:items="filteredTags"
|
||||
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
|
||||
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
|
||||
@add="onClickAddTag"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
|
||||
+1
-1
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -251,7 +251,7 @@ watch(
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<CompanyHeader
|
||||
|
||||
@@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
|
||||
+1
-1
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download="import-contacts-sample.csv"
|
||||
class="text-n-blue-text"
|
||||
class="text-n-blue-11"
|
||||
>
|
||||
{{
|
||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
|
||||
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchValue: { type: String, default: '' },
|
||||
@@ -19,6 +20,9 @@ const props = defineProps({
|
||||
segmentsId: { type: [String, Number], default: 0 },
|
||||
hasAppliedFilters: { type: Boolean, default: false },
|
||||
isFetchingList: { type: Boolean, default: false },
|
||||
useInfiniteScroll: { type: Boolean, default: false },
|
||||
hasMore: { type: Boolean, default: false },
|
||||
isLoadingMore: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -27,6 +31,7 @@ const emit = defineEmits([
|
||||
'search',
|
||||
'applyFilter',
|
||||
'clearFilters',
|
||||
'loadMore',
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
@@ -61,11 +66,19 @@ const updateCurrentPage = page => {
|
||||
const openFilter = () => {
|
||||
contactListHeaderWrapper.value?.onToggleFilters();
|
||||
};
|
||||
|
||||
const showLoadMore = computed(() => {
|
||||
return props.useInfiniteScroll && props.hasMore;
|
||||
});
|
||||
|
||||
const showPagination = computed(() => {
|
||||
return !props.useInfiniteScroll && props.showPaginationFooter;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<ContactListHeaderWrapper
|
||||
@@ -94,9 +107,14 @@ const openFilter = () => {
|
||||
@open-filter="openFilter"
|
||||
/>
|
||||
<slot name="default" />
|
||||
<ContactsLoadMore
|
||||
v-if="showLoadMore"
|
||||
:is-loading="isLoadingMore"
|
||||
@load-more="emit('loadMore')"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-center py-4">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
|
||||
:is-loading="isLoading"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@click="emit('loadMore')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -128,7 +128,7 @@ const handleInputUpdate = async () => {
|
||||
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||
!isEditingView,
|
||||
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
|
||||
'truncate hover:text-n-brand text-n-blue-text':
|
||||
'truncate hover:text-n-brand text-n-blue-11':
|
||||
isEditingView && isAttributeTypeLink,
|
||||
}"
|
||||
@click="toggleEditValue(!isEditingView)"
|
||||
|
||||
@@ -37,7 +37,7 @@ defineProps({
|
||||
<div
|
||||
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||
:class="{
|
||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
|
||||
showBackdrop,
|
||||
}"
|
||||
>
|
||||
@@ -48,14 +48,12 @@ defineProps({
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||
>
|
||||
<h2 class="text-3xl font-medium text-center text-n-slate-12">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
|
||||
@@ -126,7 +126,7 @@ const handleClick = id => {
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
|
||||
@@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between w-full gap-2">
|
||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||
<span
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||
@click="handleClick(slug)"
|
||||
>
|
||||
{{ categoryTitleWithIcon }}
|
||||
|
||||
@@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
|
||||
<div
|
||||
|
||||
@@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
|
||||
|
||||
const notificationDetails = computed(() => {
|
||||
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
|
||||
const [icon = '', color = 'text-n-blue-text'] =
|
||||
const [icon = '', color = 'text-n-blue-11'] =
|
||||
NOTIFICATION_TYPES_MAPPING[type] || [];
|
||||
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
|
||||
});
|
||||
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
|
||||
: 'i-lucide-alarm-clock-off'
|
||||
"
|
||||
class="flex-shrink-0 size-4"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium truncate"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
|
||||
>
|
||||
{{ snoozedText }}
|
||||
</span>
|
||||
|
||||
@@ -103,11 +103,11 @@ const STYLE_CONFIG = {
|
||||
solid:
|
||||
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
|
||||
faded:
|
||||
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-text outline-n-brand',
|
||||
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
|
||||
outline: 'text-n-blue-11 outline-n-brand',
|
||||
ghost:
|
||||
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
||||
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||
},
|
||||
ruby: {
|
||||
solid:
|
||||
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
|
||||
},
|
||||
slate: {
|
||||
solid:
|
||||
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||
faded:
|
||||
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
|
||||
outline:
|
||||
|
||||
@@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header class="sticky top-0 z-10 px-6">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<div
|
||||
|
||||
+1
-1
@@ -141,7 +141,7 @@ const onClickCancel = () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="onClickCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -169,7 +169,7 @@ watch(
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -254,7 +254,7 @@ const handleSubmit = async () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -220,7 +220,7 @@ const handleSubmit = async () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const dialogRef = ref(null);
|
||||
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const meta = useMapGetter('captainResponses/getMeta');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close');
|
||||
@@ -37,7 +39,7 @@ defineExpose({ dialogRef });
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
|
||||
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ const handleSubmit = async () => {
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ watch(
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -34,7 +34,7 @@ const handleImgClick = () => {
|
||||
<template>
|
||||
<div
|
||||
data-testid="changelog-card"
|
||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
|
||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
|
||||
:class="{
|
||||
'animate-fade-out pointer-events-none': isDismissing,
|
||||
'hover:shadow': isActive,
|
||||
|
||||
@@ -77,12 +77,12 @@ const queryOperatorOptions = computed(() => {
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
|
||||
value: 'and',
|
||||
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
|
||||
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-11' }),
|
||||
},
|
||||
{
|
||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
|
||||
value: 'or',
|
||||
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
|
||||
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@ export function useOperators() {
|
||||
hasInput: !NO_INPUT_OPTS.includes(value),
|
||||
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
|
||||
icon: h('span', {
|
||||
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
|
||||
class: `${filterOperatorIcon[value]} !text-n-blue-11`,
|
||||
}),
|
||||
};
|
||||
return acc;
|
||||
|
||||
@@ -3,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
|
||||
import { useTimeoutFn } from '@vueuse/core';
|
||||
import { provideMessageContext } from './provider.js';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
MESSAGE_TYPES,
|
||||
@@ -139,6 +141,8 @@ const showBackgroundHighlight = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
|
||||
|
||||
/**
|
||||
* Computes the message variant based on props
|
||||
@@ -162,6 +166,10 @@ const variant = computed(() => {
|
||||
if (props.contentAttributes?.isUnsupported)
|
||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return MESSAGE_VARIANTS.AGENT;
|
||||
}
|
||||
|
||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||
return MESSAGE_VARIANTS.BOT;
|
||||
@@ -303,6 +311,7 @@ const componentToRender = computed(() => {
|
||||
const instagramSharedTypes = [
|
||||
ATTACHMENT_TYPES.STORY_MENTION,
|
||||
ATTACHMENT_TYPES.IG_STORY,
|
||||
ATTACHMENT_TYPES.IG_STORY_REPLY,
|
||||
ATTACHMENT_TYPES.IG_POST,
|
||||
];
|
||||
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||
@@ -423,6 +432,18 @@ function handleReplyTo() {
|
||||
}
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
return {
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
iconName,
|
||||
};
|
||||
}
|
||||
|
||||
// If no sender, return bot info
|
||||
if (!props.sender) {
|
||||
return {
|
||||
@@ -450,6 +471,9 @@ const avatarInfo = computed(() => {
|
||||
});
|
||||
|
||||
const avatarTooltip = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
return t('CONVERSATION.NATIVE_APP_ADVISORY');
|
||||
}
|
||||
if (avatarInfo.value.name === '') return '';
|
||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||
});
|
||||
@@ -483,7 +507,7 @@ provideMessageContext({
|
||||
<div
|
||||
v-if="shouldRenderMessage"
|
||||
:id="`message${props.id}`"
|
||||
class="flex mb-2 w-full message-bubble-container"
|
||||
class="flex w-full mb-2 message-bubble-container"
|
||||
:data-message-id="props.id"
|
||||
:class="[
|
||||
flexOrientationClass,
|
||||
|
||||
@@ -163,7 +163,7 @@ const getInReplyToMessage = parentMessage => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul class="px-4 bg-n-background">
|
||||
<ul class="px-4 bg-n-surface-1">
|
||||
<slot name="beforeAll" />
|
||||
<template v-for="(message, index) in allMessages" :key="message.id">
|
||||
<slot
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||
|
||||
@@ -80,7 +81,7 @@ const replyToPreview = computed(() => {
|
||||
|
||||
const { content, attachments } = inReplyTo.value;
|
||||
|
||||
if (content) return content;
|
||||
if (content) return new MessageFormatter(content).formattedMessage;
|
||||
if (attachments?.length) {
|
||||
const firstAttachment = attachments[0];
|
||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||
@@ -107,9 +108,10 @@ const replyToPreview = computed(() => {
|
||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||
@click="scrollToMessage"
|
||||
>
|
||||
<span class="break-all line-clamp-2">
|
||||
{{ replyToPreview }}
|
||||
</span>
|
||||
<div
|
||||
v-dompurify-html="replyToPreview"
|
||||
class="prose prose-bubble line-clamp-2"
|
||||
/>
|
||||
</div>
|
||||
<slot />
|
||||
<MessageMeta
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||
import { MESSAGE_VARIANTS } from '../constants';
|
||||
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
|
||||
|
||||
const emit = defineEmits(['error']);
|
||||
const { variant, content, attachments } = useMessageContext();
|
||||
const { t } = useI18n();
|
||||
const { variant, content, contentAttributes, attachments } =
|
||||
useMessageContext();
|
||||
|
||||
const attachment = computed(() => {
|
||||
return attachments.value[0];
|
||||
});
|
||||
|
||||
const isStoryReply = computed(() => {
|
||||
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
|
||||
});
|
||||
|
||||
const hasImgStoryError = ref(false);
|
||||
const hasVideoStoryError = ref(false);
|
||||
|
||||
@@ -38,6 +45,9 @@ const onVideoLoadError = () => {
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
|
||||
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
|
||||
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
|
||||
</p>
|
||||
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
|
||||
<img
|
||||
v-if="!hasImgStoryError"
|
||||
|
||||
@@ -22,7 +22,7 @@ defineProps({
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
||||
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -18,12 +18,8 @@ defineProps({
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useInbox } from 'dashboard/composables/useInbox';
|
||||
import BaseBubble from './Base.vue';
|
||||
|
||||
const { inboxId } = useMessageContext();
|
||||
|
||||
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
|
||||
inboxId.value
|
||||
);
|
||||
|
||||
const unsupportedMessageKey = computed(() => {
|
||||
if (isAFacebookInbox.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
|
||||
if (isAnInstagramChannel.value)
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
|
||||
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
|
||||
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
||||
{{ $t(unsupportedMessageKey) }}
|
||||
</BaseBubble>
|
||||
</template>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const ATTACHMENT_TYPES = {
|
||||
EMBED: 'embed',
|
||||
IG_POST: 'ig_post',
|
||||
IG_STORY: 'ig_story',
|
||||
IG_STORY_REPLY: 'ig_story_reply',
|
||||
};
|
||||
|
||||
export const CONTENT_TYPES = {
|
||||
|
||||
@@ -71,7 +71,7 @@ const pageInfo = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -24,10 +25,7 @@ const reauthorizationRequired = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
|
||||
:class="{ 'bg-n-solid-blue': active }"
|
||||
>
|
||||
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
|
||||
<ChannelIcon :inbox="inbox" class="size-3" />
|
||||
</span>
|
||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ const toggleSidebar = () => {
|
||||
<div
|
||||
v-if="!isConversationRoute"
|
||||
id="mobile-sidebar-launcher"
|
||||
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
|
||||
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
|
||||
:class="[
|
||||
{
|
||||
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { h, ref, computed, onMounted } from 'vue';
|
||||
import { provideSidebarContext } from './provider';
|
||||
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -8,6 +8,7 @@ import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
@@ -15,7 +16,9 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import SidebarChangelogButton from './SidebarChangelogButton.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
@@ -42,6 +45,10 @@ const { t } = useI18n();
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
);
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
@@ -58,11 +65,85 @@ const expandedItem = ref(null);
|
||||
const setExpandedItem = name => {
|
||||
expandedItem.value = expandedItem.value === name ? null : name;
|
||||
};
|
||||
|
||||
const {
|
||||
sidebarWidth,
|
||||
isCollapsed,
|
||||
setSidebarWidth,
|
||||
saveWidth,
|
||||
snapToCollapsed,
|
||||
snapToExpanded,
|
||||
COLLAPSED_THRESHOLD,
|
||||
} = useSidebarResize();
|
||||
|
||||
// On mobile, sidebar is always expanded (flyout mode)
|
||||
const isEffectivelyCollapsed = computed(
|
||||
() => !isMobile.value && isCollapsed.value
|
||||
);
|
||||
|
||||
// Resize handle logic
|
||||
const isResizing = ref(false);
|
||||
const startX = ref(0);
|
||||
const startWidth = ref(0);
|
||||
|
||||
provideSidebarContext({
|
||||
expandedItem,
|
||||
setExpandedItem,
|
||||
isCollapsed: isEffectivelyCollapsed,
|
||||
sidebarWidth,
|
||||
isResizing,
|
||||
});
|
||||
|
||||
// Get clientX from mouse or touch event
|
||||
const getClientX = event =>
|
||||
event.touches ? event.touches[0].clientX : event.clientX;
|
||||
|
||||
const onResizeStart = event => {
|
||||
isResizing.value = true;
|
||||
startX.value = getClientX(event);
|
||||
startWidth.value = sidebarWidth.value;
|
||||
Object.assign(document.body.style, {
|
||||
cursor: 'col-resize',
|
||||
userSelect: 'none',
|
||||
});
|
||||
// Prevent default to avoid scrolling on touch
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onResizeMove = event => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
const delta = isRTL.value
|
||||
? startX.value - getClientX(event)
|
||||
: getClientX(event) - startX.value;
|
||||
setSidebarWidth(startWidth.value + delta);
|
||||
};
|
||||
|
||||
const onResizeEnd = () => {
|
||||
if (!isResizing.value) return;
|
||||
|
||||
isResizing.value = false;
|
||||
Object.assign(document.body.style, { cursor: '', userSelect: '' });
|
||||
|
||||
// Snap to collapsed state if below threshold
|
||||
if (sidebarWidth.value < COLLAPSED_THRESHOLD) {
|
||||
snapToCollapsed();
|
||||
} else {
|
||||
saveWidth();
|
||||
}
|
||||
};
|
||||
|
||||
const onResizeHandleDoubleClick = () => {
|
||||
if (isCollapsed.value) snapToExpanded();
|
||||
else snapToCollapsed();
|
||||
};
|
||||
|
||||
// Support both mouse and touch events
|
||||
useEventListener(document, 'mousemove', onResizeMove);
|
||||
useEventListener(document, 'mouseup', onResizeEnd);
|
||||
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
|
||||
useEventListener(document, 'touchend', onResizeEnd);
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||
const teams = useMapGetter('teams/getMyTeams');
|
||||
@@ -192,6 +273,7 @@ const menuItems = computed(() => {
|
||||
children: sortedInboxes.value.map(inbox => ({
|
||||
name: `${inbox.name}-${inbox.id}`,
|
||||
label: inbox.name,
|
||||
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
|
||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||
component: leafProps =>
|
||||
h(ChannelLeaf, {
|
||||
@@ -210,7 +292,7 @@ const menuItems = computed(() => {
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute('label_conversations', {
|
||||
@@ -338,7 +420,7 @@ const menuItems = computed(() => {
|
||||
name: `${label.title}-${label.id}`,
|
||||
label: label.title,
|
||||
icon: h('span', {
|
||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
||||
class: `size-[8px] rounded-sm`,
|
||||
style: { backgroundColor: label.color },
|
||||
}),
|
||||
to: accountScopedRoute(
|
||||
@@ -604,32 +686,56 @@ const menuItems = computed(() => {
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
]"
|
||||
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
||||
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
{
|
||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||
'transition-transform duration-200 ease-out md:transition-[width]':
|
||||
!isResizing,
|
||||
},
|
||||
]"
|
||||
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex gap-2 items-center px-2 min-w-0">
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
<section
|
||||
class="grid"
|
||||
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-center min-w-0"
|
||||
:class="{
|
||||
'justify-center px-1': isEffectivelyCollapsed,
|
||||
'px-2': !isEffectivelyCollapsed,
|
||||
}"
|
||||
>
|
||||
<template v-if="isEffectivelyCollapsed">
|
||||
<SidebarAccountSwitcher
|
||||
is-collapsed
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-2 px-2">
|
||||
<div
|
||||
class="flex gap-2"
|
||||
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="!isEffectivelyCollapsed"
|
||||
:to="{ name: 'search' }"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
|
||||
>
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||
<span class="flex-grow text-left">
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
|
||||
<span class="flex-grow text-start text-n-slate-10">
|
||||
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
||||
</span>
|
||||
<span
|
||||
@@ -638,21 +744,41 @@ const menuItems = computed(() => {
|
||||
{{ searchShortcut }}
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-else
|
||||
:to="{ name: 'search' }"
|
||||
class="flex items-center justify-center size-8 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out hover:bg-n-alpha-2 dark:hover:bg-n-slate-9/30"
|
||||
:title="t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||
>
|
||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle }">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
||||
class="dark:hover:!bg-n-slate-9/30"
|
||||
:class="[
|
||||
isEffectivelyCollapsed
|
||||
? '!size-8 !outline-n-weak !text-n-slate-11'
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
]"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
|
||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||
<nav
|
||||
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
|
||||
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
|
||||
>
|
||||
<ul
|
||||
class="flex flex-col gap-1 m-0 list-none min-w-0"
|
||||
:class="{ 'items-center': isEffectivelyCollapsed }"
|
||||
>
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
@@ -664,18 +790,43 @@ const menuItems = computed(() => {
|
||||
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
|
||||
/>
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
v-if="
|
||||
isOnChatwootCloud &&
|
||||
!isACustomBrandedInstance &&
|
||||
!isEffectivelyCollapsed
|
||||
"
|
||||
/>
|
||||
<SidebarChangelogButton
|
||||
v-if="
|
||||
isOnChatwootCloud &&
|
||||
!isACustomBrandedInstance &&
|
||||
isEffectivelyCollapsed
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
:is-collapsed="isEffectivelyCollapsed"
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Resize Handle (desktop only) -->
|
||||
<div
|
||||
class="hidden md:block absolute top-0 h-full w-1 cursor-col-resize z-40 ltr:right-0 rtl:left-0 group"
|
||||
@mousedown="onResizeStart"
|
||||
@touchstart="onResizeStart"
|
||||
@dblclick="onResizeHandleDoubleClick"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 h-full w-px ltr:right-0 rtl:left-0 bg-transparent group-hover:bg-n-brand transition-colors"
|
||||
:class="{ 'bg-n-brand': isResizing }"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import ButtonNext from 'next/button/Button.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
|
||||
import {
|
||||
DropdownContainer,
|
||||
@@ -13,6 +14,13 @@ import {
|
||||
DropdownItem,
|
||||
} from 'next/dropdown-menu/base';
|
||||
|
||||
defineProps({
|
||||
isCollapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['showCreateAccountModal']);
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -45,7 +53,19 @@ const emitNewAccount = () => {
|
||||
<template>
|
||||
<DropdownContainer>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<!-- Collapsed view: Logo trigger -->
|
||||
<button
|
||||
v-if="isCollapsed"
|
||||
class="grid flex-shrink-0 place-content-center p-2 rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
:title="currentAccount.name"
|
||||
@click="toggle"
|
||||
>
|
||||
<Logo class="size-7" />
|
||||
</button>
|
||||
<!-- Expanded view: Account name trigger -->
|
||||
<button
|
||||
v-else
|
||||
id="sidebar-account-switcher"
|
||||
:data-account-id="accountId"
|
||||
aria-haspopup="listbox"
|
||||
@@ -73,7 +93,10 @@ const emitNewAccount = () => {
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
|
||||
<DropdownBody
|
||||
v-if="showAccountSwitcher || isCollapsed"
|
||||
class="min-w-80 z-50"
|
||||
>
|
||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||
<DropdownItem
|
||||
v-for="account in sortedCurrentUserAccounts"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
|
||||
const [isOpen, toggleOpen] = useToggle(false);
|
||||
const changelogCard = useTemplateRef('changelogCard');
|
||||
|
||||
const isLoading = computed(() => changelogCard.value?.isLoading || false);
|
||||
const hasArticles = computed(
|
||||
() => changelogCard.value?.unDismissedPosts?.length > 0
|
||||
);
|
||||
const shouldShowButton = computed(() => !isLoading.value && hasArticles.value);
|
||||
|
||||
const closePopover = () => {
|
||||
if (isOpen.value) {
|
||||
toggleOpen(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-click-outside="closePopover" class="relative mb-2">
|
||||
<Button
|
||||
v-if="shouldShowButton"
|
||||
icon="i-lucide-sparkles"
|
||||
ghost
|
||||
slate
|
||||
:class="{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen }"
|
||||
@click="toggleOpen()"
|
||||
/>
|
||||
|
||||
<!-- Always render card so it can fetch data, control visibility with v-show -->
|
||||
<div
|
||||
v-show="isOpen && hasArticles"
|
||||
class="absolute ltr:left-full rtl:right-full bottom-0 ltr:ml-4 rtl:mr-4 z-40 bg-transparent w-52"
|
||||
>
|
||||
<SidebarChangelogCard
|
||||
ref="changelogCard"
|
||||
class="[&>div]:!pb-0 [&>div]:!px-0 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,6 +4,10 @@ import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-car
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import changelogAPI from 'dashboard/api/changelog';
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const MAX_DISMISSED_SLUGS = 5;
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
@@ -90,6 +94,11 @@ const handleImgClick = ({ index }) => {
|
||||
handleReadMore();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isLoading,
|
||||
unDismissedPosts,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchChangelog();
|
||||
});
|
||||
@@ -98,6 +107,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<GroupedStackedChangelogCard
|
||||
v-if="unDismissedPosts.length > 0"
|
||||
v-bind="$attrs"
|
||||
:posts="unDismissedPosts"
|
||||
:current-index="currentIndex"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
label: { type: String, required: true },
|
||||
children: { type: Array, default: () => [] },
|
||||
activeChild: { type: Object, default: undefined },
|
||||
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||
|
||||
const router = useRouter();
|
||||
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||
|
||||
const expandedSubGroup = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
const topPosition = ref(0);
|
||||
const isRTL = useMapGetter('accounts/isRTL');
|
||||
const skipTransition = ref(true);
|
||||
|
||||
const toggleSubGroup = name => {
|
||||
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||
};
|
||||
|
||||
const navigateAndClose = to => {
|
||||
router.push(to);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const isActive = child => props.activeChild?.name === child.name;
|
||||
|
||||
const getAccessibleSubChildren = children =>
|
||||
children.filter(c => isAllowed(c.to));
|
||||
|
||||
const renderIcon = icon => ({
|
||||
component: typeof icon === 'object' ? icon : Icon,
|
||||
props: typeof icon === 'string' ? { icon } : null,
|
||||
});
|
||||
|
||||
const transition = computed(() =>
|
||||
skipTransition.value
|
||||
? {}
|
||||
: {
|
||||
enterActiveClass: 'transition-all duration-200 ease-out',
|
||||
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||
enterToClass: 'opacity-100 translate-y-0 max-h-96',
|
||||
leaveActiveClass: 'transition-all duration-150 ease-in',
|
||||
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
|
||||
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||
}
|
||||
);
|
||||
|
||||
const accessibleChildren = computed(() => {
|
||||
return props.children.filter(child => {
|
||||
if (child.children) {
|
||||
return child.children.some(subChild => isAllowed(subChild.to));
|
||||
}
|
||||
return child.to && isAllowed(child.to);
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
|
||||
// Auto-expand subgroup if active child is inside it
|
||||
if (props.activeChild) {
|
||||
const parentGroup = props.children.find(child =>
|
||||
child.children?.some(subChild => subChild.name === props.activeChild.name)
|
||||
);
|
||||
if (parentGroup) {
|
||||
expandedSubGroup.value = parentGroup.name;
|
||||
// Wait for the subgroup expansion to render before measuring height
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
if (!props.triggerRect) return;
|
||||
|
||||
const viewportHeight = window.innerHeight;
|
||||
const popoverHeight = popoverRef.value?.offsetHeight || 300;
|
||||
const { top: triggerTop } = props.triggerRect;
|
||||
|
||||
// Adjust position if popover would overflow viewport
|
||||
topPosition.value =
|
||||
triggerTop + popoverHeight > viewportHeight - 20
|
||||
? Math.max(20, viewportHeight - popoverHeight - 20)
|
||||
: triggerTop;
|
||||
|
||||
await nextTick();
|
||||
skipTransition.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeleportWithDirection>
|
||||
<div
|
||||
ref="popoverRef"
|
||||
class="fixed z-[100] min-w-[200px] max-w-[280px]"
|
||||
:style="{
|
||||
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
|
||||
top: `${topPosition}px`,
|
||||
}"
|
||||
@mouseenter="emit('mouseenter')"
|
||||
@mouseleave="emit('mouseleave')"
|
||||
>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
|
||||
>
|
||||
<div
|
||||
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
|
||||
>
|
||||
{{ label }}
|
||||
</div>
|
||||
<ul
|
||||
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
|
||||
>
|
||||
<template v-for="child in accessibleChildren" :key="child.name">
|
||||
<!-- SubGroup with children -->
|
||||
<li v-if="child.children" class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||
@click="toggleSubGroup(child.name)"
|
||||
>
|
||||
<Icon
|
||||
v-if="child.icon"
|
||||
:icon="child.icon"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||
<span
|
||||
class="size-3 transition-transform i-lucide-chevron-down"
|
||||
:class="{
|
||||
'rotate-180': expandedSubGroup === child.name,
|
||||
}"
|
||||
/>
|
||||
</button>
|
||||
<Transition v-bind="transition">
|
||||
<ul
|
||||
v-if="expandedSubGroup === child.name"
|
||||
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
|
||||
>
|
||||
<li
|
||||
v-for="subChild in getAccessibleSubChildren(child.children)"
|
||||
:key="subChild.name"
|
||||
class="py-0.5"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
|
||||
'text-n-slate-11 hover:bg-n-alpha-2':
|
||||
!isActive(subChild),
|
||||
}"
|
||||
@click="navigateAndClose(subChild.to)"
|
||||
>
|
||||
<component
|
||||
:is="renderIcon(subChild.icon).component"
|
||||
v-if="subChild.icon"
|
||||
v-bind="renderIcon(subChild.icon).props"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
<!-- Direct child item -->
|
||||
<li v-else class="py-0.5">
|
||||
<button
|
||||
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive(child),
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
|
||||
}"
|
||||
@click="navigateAndClose(child.to)"
|
||||
>
|
||||
<component
|
||||
:is="renderIcon(child.icon).component"
|
||||
v-if="child.icon"
|
||||
v-bind="renderIcon(child.icon).props"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, watch, nextTick } from 'vue';
|
||||
import { useSidebarContext } from './provider';
|
||||
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
|
||||
import { useSidebarContext, usePopoverState } from './provider';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||
import SidebarSubGroup from './SidebarSubGroup.vue';
|
||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
@@ -25,8 +27,18 @@ const {
|
||||
resolvePermissions,
|
||||
resolveFeatureFlag,
|
||||
isAllowed,
|
||||
isCollapsed,
|
||||
isResizing,
|
||||
} = useSidebarContext();
|
||||
|
||||
const {
|
||||
activePopover,
|
||||
setActivePopover,
|
||||
closeActivePopover,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
} = usePopoverState();
|
||||
|
||||
const navigableChildren = computed(() => {
|
||||
return props.children?.flatMap(child => child.children || child) || [];
|
||||
});
|
||||
@@ -39,6 +51,54 @@ const hasChildren = computed(
|
||||
() => Array.isArray(props.children) && props.children.length > 0
|
||||
);
|
||||
|
||||
// Use shared popover state - only one popover can be open at a time
|
||||
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||
const triggerRef = ref(null);
|
||||
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||
|
||||
const openPopover = () => {
|
||||
if (triggerRef.value) {
|
||||
const rect = triggerRef.value.getBoundingClientRect();
|
||||
triggerRect.value = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
bottom: rect.bottom,
|
||||
right: rect.right,
|
||||
};
|
||||
}
|
||||
setActivePopover(props.name);
|
||||
};
|
||||
|
||||
const closePopover = () => {
|
||||
if (activePopover.value === props.name) {
|
||||
closeActivePopover();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!hasChildren.value || isResizing.value) return;
|
||||
cancelClose();
|
||||
openPopover();
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (!hasChildren.value) return;
|
||||
scheduleClose(200);
|
||||
};
|
||||
|
||||
const handlePopoverMouseEnter = () => {
|
||||
cancelClose();
|
||||
};
|
||||
|
||||
const handlePopoverMouseLeave = () => {
|
||||
scheduleClose(100);
|
||||
};
|
||||
|
||||
// Close popover when mouse leaves the window
|
||||
const handleWindowBlur = () => {
|
||||
closeActivePopover();
|
||||
};
|
||||
|
||||
const accessibleItems = computed(() => {
|
||||
if (!hasChildren.value) return [];
|
||||
return props.children.filter(child => {
|
||||
@@ -107,6 +167,13 @@ const hasActiveChild = computed(() => {
|
||||
return activeChild.value !== undefined;
|
||||
});
|
||||
|
||||
const handleCollapsedClick = () => {
|
||||
if (hasChildren.value && hasAccessibleChildren.value) {
|
||||
const firstItem = accessibleItems.value[0];
|
||||
router.push(firstItem.to);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTrigger = () => {
|
||||
if (
|
||||
hasAccessibleChildren.value &&
|
||||
@@ -125,6 +192,13 @@ onMounted(async () => {
|
||||
if (hasActiveChild.value) {
|
||||
setExpandedItem(props.name);
|
||||
}
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
document.addEventListener('mouseleave', handleWindowBlur);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
document.removeEventListener('mouseleave', handleWindowBlur);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -145,45 +219,82 @@ watch(
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="grid gap-1 text-sm cursor-pointer select-none"
|
||||
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
|
||||
>
|
||||
<SidebarGroupHeader
|
||||
:icon
|
||||
:name
|
||||
:label
|
||||
:to
|
||||
:getter-keys="getterKeys"
|
||||
:is-active="isActive"
|
||||
:has-active-child="hasActiveChild"
|
||||
:expandable="hasChildren"
|
||||
:is-expanded="isExpanded"
|
||||
@toggle="toggleTrigger"
|
||||
/>
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:is-expanded="isExpanded"
|
||||
<!-- Collapsed State -->
|
||||
<template v-if="isCollapsed">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<component
|
||||
:is="to && !hasChildren ? 'router-link' : 'button'"
|
||||
ref="triggerRef"
|
||||
:to="to && !hasChildren ? to : undefined"
|
||||
type="button"
|
||||
class="flex items-center justify-center size-10 rounded-lg"
|
||||
:class="{
|
||||
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||
}"
|
||||
:title="label"
|
||||
@click="hasChildren ? handleCollapsedClick() : undefined"
|
||||
>
|
||||
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||
</component>
|
||||
<SidebarCollapsedPopover
|
||||
v-if="hasChildren && isPopoverOpen"
|
||||
:label="label"
|
||||
:children="children"
|
||||
:active-child="activeChild"
|
||||
:trigger-rect="triggerRect"
|
||||
@close="closePopover"
|
||||
@mouseenter="handlePopoverMouseEnter"
|
||||
@mouseleave="handlePopoverMouseLeave"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-else-if="isExpandable && isExpanded">
|
||||
<SidebarGroupEmptyLeaf />
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Expanded State -->
|
||||
<template v-else>
|
||||
<SidebarGroupHeader
|
||||
:icon
|
||||
:name
|
||||
:label
|
||||
:to
|
||||
:getter-keys="getterKeys"
|
||||
:is-active="isActive"
|
||||
:has-active-child="hasActiveChild"
|
||||
:expandable="hasChildren"
|
||||
:is-expanded="isExpanded"
|
||||
@toggle="toggleTrigger"
|
||||
/>
|
||||
<ul
|
||||
v-if="hasChildren"
|
||||
v-show="isExpanded || hasActiveChild"
|
||||
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||
>
|
||||
<template v-for="child in children" :key="child.name">
|
||||
<SidebarSubGroup
|
||||
v-if="child.children"
|
||||
:label="child.label"
|
||||
:icon="child.icon"
|
||||
:children="child.children"
|
||||
:is-expanded="isExpanded"
|
||||
:active-child="activeChild"
|
||||
/>
|
||||
<SidebarGroupLeaf
|
||||
v-else-if="isAllowed(child.to)"
|
||||
v-show="isExpanded || activeChild?.name === child.name"
|
||||
v-bind="child"
|
||||
:active="activeChild?.name === child.name"
|
||||
/>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-else-if="isExpandable && isExpanded">
|
||||
<SidebarGroupEmptyLeaf />
|
||||
</ul>
|
||||
</template>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@ const count = computed(() =>
|
||||
<template>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
|
||||
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
|
||||
role="button"
|
||||
draggable="false"
|
||||
:to="to"
|
||||
:title="label"
|
||||
:class="{
|
||||
'text-n-blue-text bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
||||
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
||||
'text-n-slate-12 font-medium': hasActiveChild,
|
||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||
}"
|
||||
@@ -45,15 +45,21 @@ const count = computed(() =>
|
||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0">
|
||||
<span class="text-sm font-medium leading-5 truncate">
|
||||
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||
<span
|
||||
class="truncate"
|
||||
:class="{
|
||||
'text-body-main': !isActive,
|
||||
'font-medium text-sm': isActive || hasActiveChild,
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="dynamicCount && !expandable"
|
||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||
:class="{
|
||||
'text-n-blue-text outline-n-slate-6': isActive,
|
||||
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||
'text-n-slate-11 outline-n-strong': !isActive,
|
||||
}"
|
||||
>
|
||||
|
||||
@@ -25,15 +25,15 @@ const shouldRenderComponent = computed(() => {
|
||||
:permissions="resolvePermissions(to)"
|
||||
:feature-flag="resolveFeatureFlag(to)"
|
||||
as="li"
|
||||
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
|
||||
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||
>
|
||||
<component
|
||||
:is="to ? 'router-link' : 'div'"
|
||||
:to="to"
|
||||
:title="label"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
|
||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||
:class="{
|
||||
'text-n-blue-text bg-n-alpha-2 active': active,
|
||||
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||
}"
|
||||
>
|
||||
<component
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
} from 'next/dropdown-menu/base';
|
||||
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
|
||||
|
||||
defineProps({
|
||||
isCollapsed: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'openKeyShortcutModal']);
|
||||
|
||||
defineOptions({
|
||||
@@ -120,11 +124,19 @@ const allowedMenuItems = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
|
||||
<DropdownContainer
|
||||
class="relative min-w-0"
|
||||
:class="isCollapsed ? 'w-auto' : 'w-full'"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<button
|
||||
class="flex gap-2 items-center p-1 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||
class="flex gap-2 items-center p-1 text-left rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||
:class="[
|
||||
{ 'bg-n-alpha-1': isOpen },
|
||||
isCollapsed ? 'justify-center' : 'w-full',
|
||||
]"
|
||||
:title="isCollapsed ? currentUser.available_name : undefined"
|
||||
@click="toggle"
|
||||
>
|
||||
<Avatar
|
||||
@@ -135,7 +147,7 @@ const allowedMenuItems = computed(() => {
|
||||
class="flex-shrink-0"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div v-if="!isCollapsed" class="min-w-0">
|
||||
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
|
||||
{{ currentUser.available_name }}
|
||||
</div>
|
||||
|
||||
@@ -48,11 +48,15 @@ useEventListener(scrollableContainer, 'scroll', () => {
|
||||
:icon
|
||||
class="my-1"
|
||||
/>
|
||||
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
|
||||
<ul
|
||||
v-if="children.length"
|
||||
class="m-0 list-none reset-base relative group min-w-0"
|
||||
>
|
||||
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
|
||||
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
|
||||
<div
|
||||
ref="scrollableContainer"
|
||||
class="min-w-0"
|
||||
:class="{
|
||||
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
|
||||
}"
|
||||
@@ -68,7 +72,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
|
||||
<div
|
||||
v-if="isScrollable && isExpanded"
|
||||
v-show="!scrollEnd"
|
||||
class="absolute bg-gradient-to-t from-n-solid-2 w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
||||
@@ -1,9 +1,87 @@
|
||||
import { inject, provide } from 'vue';
|
||||
import { inject, provide, ref, computed } from 'vue';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const SidebarControl = Symbol('SidebarControl');
|
||||
|
||||
const DEFAULT_WIDTH = 200;
|
||||
const MIN_WIDTH = 56;
|
||||
const COLLAPSED_THRESHOLD = 160;
|
||||
const MAX_WIDTH = 320;
|
||||
|
||||
// Shared state for active popover (only one can be open at a time)
|
||||
const activePopover = ref(null);
|
||||
let globalCloseTimeout = null;
|
||||
|
||||
export function useSidebarResize() {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const sidebarWidth = ref(uiSettings.value.sidebar_width || DEFAULT_WIDTH);
|
||||
const isCollapsed = computed(() => sidebarWidth.value < COLLAPSED_THRESHOLD);
|
||||
|
||||
const setSidebarWidth = width => {
|
||||
sidebarWidth.value = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width));
|
||||
};
|
||||
|
||||
const saveWidth = () => {
|
||||
updateUISettings({ sidebar_width: sidebarWidth.value });
|
||||
};
|
||||
|
||||
const snapToCollapsed = () => {
|
||||
sidebarWidth.value = MIN_WIDTH;
|
||||
updateUISettings({ sidebar_width: MIN_WIDTH });
|
||||
};
|
||||
|
||||
const snapToExpanded = () => {
|
||||
sidebarWidth.value = DEFAULT_WIDTH;
|
||||
updateUISettings({ sidebar_width: DEFAULT_WIDTH });
|
||||
};
|
||||
|
||||
return {
|
||||
sidebarWidth,
|
||||
isCollapsed,
|
||||
setSidebarWidth,
|
||||
saveWidth,
|
||||
snapToCollapsed,
|
||||
snapToExpanded,
|
||||
MIN_WIDTH,
|
||||
MAX_WIDTH,
|
||||
COLLAPSED_THRESHOLD,
|
||||
DEFAULT_WIDTH,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePopoverState() {
|
||||
const setActivePopover = name => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
activePopover.value = name;
|
||||
};
|
||||
|
||||
const closeActivePopover = () => {
|
||||
activePopover.value = null;
|
||||
};
|
||||
|
||||
const scheduleClose = (delay = 150) => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
globalCloseTimeout = setTimeout(() => {
|
||||
closeActivePopover();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const cancelClose = () => {
|
||||
clearTimeout(globalCloseTimeout);
|
||||
};
|
||||
|
||||
return {
|
||||
activePopover,
|
||||
setActivePopover,
|
||||
closeActivePopover,
|
||||
scheduleClose,
|
||||
cancelClose,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSidebarContext() {
|
||||
const context = inject(SidebarControl, null);
|
||||
if (context === null) {
|
||||
@@ -11,7 +89,6 @@ export function useSidebarContext() {
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { shouldShow } = usePolicy();
|
||||
|
||||
const resolvePath = to => {
|
||||
|
||||
@@ -27,7 +27,7 @@ const updateValue = () => {
|
||||
>
|
||||
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||
<span
|
||||
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
||||
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-out"
|
||||
:class="
|
||||
modelValue
|
||||
? 'translate-x-3 bg-n-background'
|
||||
|
||||
@@ -84,7 +84,7 @@ const showDivider = index => {
|
||||
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
||||
:class="[
|
||||
activeTab === index
|
||||
? 'text-n-blue-text scale-100'
|
||||
? 'text-n-blue-11 scale-100'
|
||||
: 'text-n-slate-10 scale-[0.98]',
|
||||
]"
|
||||
@click="selectTab(index)"
|
||||
|
||||
@@ -47,7 +47,7 @@ const onToggle = () => {
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<slot name="button" />
|
||||
<div class="flex justify-end w-3 text-n-blue-text cursor-pointer">
|
||||
<div class="flex justify-end w-3 text-n-blue-11 cursor-pointer">
|
||||
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
|
||||
<fluent-icon v-else size="24" icon="add" type="solid" />
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@ const onToggle = () => {
|
||||
</button>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="bg-n-background outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
|
||||
class="outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
|
||||
:class="compact ? 'p-0' : 'px-2 py-4'"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -902,7 +902,7 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
|
||||
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]',
|
||||
|
||||
@@ -57,7 +57,7 @@ const toggleConversationLayout = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 h-12"
|
||||
class="flex items-center justify-between gap-2 px-3 h-[3.25rem]"
|
||||
:class="{
|
||||
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
|
||||
}"
|
||||
|
||||
@@ -133,7 +133,7 @@ onMounted(() => {
|
||||
<div
|
||||
v-if="shouldShowCopilotPanel"
|
||||
v-on-click-outside="() => closeCopilotPanel()"
|
||||
class="bg-n-background h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
class="bg-n-surface-2 h-full overflow-hidden flex-col fixed top-0 ltr:right-0 rtl:left-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
:class="[
|
||||
{
|
||||
'md:flex': shouldShowCopilotPanel,
|
||||
|
||||
@@ -116,7 +116,7 @@ const dayClasses = day => ({
|
||||
(isInRange(day) || isHoveringInRange(day)) &&
|
||||
!isSelectedStartOrEndDate(day) &&
|
||||
isInCurrentMonth(day),
|
||||
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
|
||||
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-11':
|
||||
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -47,23 +47,23 @@ const onTabClick = event => {
|
||||
class="flex-shrink-0 my-0 mx-2 ltr:first:ml-0 rtl:first:mr-0 ltr:last:mr-0 rtl:last:ml-0 hover:text-n-slate-12"
|
||||
>
|
||||
<a
|
||||
class="flex items-center flex-row border-b select-none cursor-pointer text-sm relative top-[1px] transition-[border-color] duration-[150ms] ease-[cubic-bezier(0.37,0,0.63,1)]"
|
||||
class="flex items-center flex-row select-none cursor-pointer relative after:absolute after:bottom-px after:left-0 after:right-0 after:h-[2px] after:rounded-full after:transition-all after:duration-200 text-button"
|
||||
:class="[
|
||||
active
|
||||
? 'border-b border-n-brand text-n-blue-text'
|
||||
: 'border-transparent text-n-slate-11',
|
||||
isCompact ? 'py-2 text-sm' : 'text-base py-3',
|
||||
? 'text-n-blue-11 after:bg-n-brand after:opacity-100'
|
||||
: 'text-n-slate-11 after:bg-transparent after:opacity-0',
|
||||
isCompact ? 'py-2.5' : '!text-base py-3',
|
||||
]"
|
||||
@click="onTabClick"
|
||||
>
|
||||
{{ name }}
|
||||
<div
|
||||
v-if="showBadge"
|
||||
class="rounded-md h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 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-brand/10 dark:bg-n-brand/20 text-n-blue-text'
|
||||
: 'bg-n-alpha-black2 dark:bg-n-solid-3 text-n-slate-11',
|
||||
? 'bg-n-blue-3 text-n-blue-11'
|
||||
: 'bg-n-alpha-1 text-n-slate-10',
|
||||
]"
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
|
||||
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
|
||||
@@ -91,7 +91,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-background relative"
|
||||
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-surface-1 relative"
|
||||
:class="{
|
||||
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
|
||||
}"
|
||||
@@ -104,7 +104,7 @@ export default {
|
||||
<woot-tabs
|
||||
v-if="dashboardApps.length && currentChat.id"
|
||||
:index="activeIndex"
|
||||
class="-mt-px border-t border-t-n-background"
|
||||
class="h-10"
|
||||
@change="onDashboardAppTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
@@ -114,7 +114,6 @@ export default {
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
is-compact
|
||||
class="[&_a]:pt-1"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div v-show="!activeIndex" class="flex h-full min-h-0 m-0">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
@@ -14,6 +15,8 @@ import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import ConversationSummary from './ConversationSummary.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
@@ -46,8 +49,10 @@ const emit = defineEmits([
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const hovered = ref(false);
|
||||
const summaryRef = ref(null);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({
|
||||
x: null,
|
||||
@@ -236,9 +241,8 @@ const deleteConversation = () => {
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 border-n-weak':
|
||||
isActiveChat,
|
||||
'bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@@ -345,12 +349,15 @@ const deleteConversation = () => {
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex items-center gap-1 ml-auto">
|
||||
<ConversationSummary ref="summaryRef" :chat="chat" />
|
||||
<span class="font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
@@ -367,6 +374,26 @@ const deleteConversation = () => {
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
<!-- Expanded Summary Section -->
|
||||
<div
|
||||
v-if="summaryRef?.isExpanded"
|
||||
class="mt-2 mx-2 mb-1 p-3 bg-n-alpha-1 dark:bg-n-alpha-2 rounded-lg"
|
||||
>
|
||||
<div v-if="summaryRef?.isLoading" class="flex items-center gap-2">
|
||||
<Spinner :size="16" class="text-n-slate-10" />
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CHAT_LIST.SUMMARY.LOADING') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="summaryRef?.error" class="text-xs text-n-ruby-11">
|
||||
{{ summaryRef.error }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="summaryRef?.formattedSummary"
|
||||
class="text-xs text-n-slate-11 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:list-decimal [&_ol]:pl-4 [&_li]:my-0.5 [&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0 [&_strong]:text-n-slate-12"
|
||||
v-html="summaryRef.formattedSummary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
|
||||
@@ -95,7 +95,7 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
<template>
|
||||
<div
|
||||
ref="conversationHeader"
|
||||
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
|
||||
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 pt-3 pb-2 h-24 xl:h-12"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
|
||||
|
||||
@@ -42,7 +42,7 @@ const closeContactPanel = () => {
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => closeContactPanel()"
|
||||
class="bg-n-background h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
:class="[
|
||||
{
|
||||
'md:flex': activeTab === 0,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import TasksAPI from 'dashboard/api/captain/tasks';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const captainTasksEnabled = computed(() => {
|
||||
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
|
||||
});
|
||||
|
||||
const cachedSummary = computed(() => props.chat?.cached_summary || '');
|
||||
const cachedSummaryAt = computed(() => props.chat?.cached_summary_at || 0);
|
||||
const lastActivityAt = computed(() => props.chat?.last_activity_at || 0);
|
||||
|
||||
const isStale = computed(() => {
|
||||
if (!cachedSummaryAt.value) return true;
|
||||
return lastActivityAt.value > cachedSummaryAt.value;
|
||||
});
|
||||
|
||||
const formattedSummary = computed(() => {
|
||||
return cachedSummary.value ? formatMessage(cachedSummary.value) : '';
|
||||
});
|
||||
|
||||
const fetchSummary = async () => {
|
||||
isLoading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const result = await TasksAPI.summarize(props.chat.id, {
|
||||
forceRegenerate: false,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedSummary },
|
||||
} = result;
|
||||
|
||||
if (generatedSummary) {
|
||||
store.dispatch('updateConversationCachedSummary', {
|
||||
conversationId: props.chat.id,
|
||||
cachedSummary: generatedSummary,
|
||||
cachedSummaryAt: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError' && e.name !== 'CanceledError') {
|
||||
error.value = e.response?.data?.error || t('CHAT_LIST.SUMMARY.ERROR');
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSummary = async () => {
|
||||
if (isExpanded.value) {
|
||||
isExpanded.value = false;
|
||||
return;
|
||||
}
|
||||
isExpanded.value = true;
|
||||
useTrack(CAPTAIN_EVENTS.SUMMARIZE_USED, {
|
||||
conversationId: props.chat.id,
|
||||
uiFrom: 'conversation_list',
|
||||
});
|
||||
// Only fetch if no cached summary
|
||||
if (!cachedSummary.value && !error.value) {
|
||||
await fetchSummary();
|
||||
}
|
||||
};
|
||||
|
||||
const onButtonClick = e => {
|
||||
e.stopPropagation();
|
||||
toggleSummary();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
isExpanded,
|
||||
isLoading,
|
||||
error,
|
||||
formattedSummary,
|
||||
captainTasksEnabled,
|
||||
isStale,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Button
|
||||
v-if="captainTasksEnabled"
|
||||
icon="i-material-symbols-auto-awesome"
|
||||
slate
|
||||
ghost
|
||||
xs
|
||||
:title="t('CHAT_LIST.SUMMARY.TITLE')"
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
:class="{ '!opacity-100': isExpanded }"
|
||||
@click="onButtonClick"
|
||||
/>
|
||||
</template>
|
||||
@@ -58,7 +58,7 @@ export default {
|
||||
) {
|
||||
return 'h-full overflow-auto w-full';
|
||||
}
|
||||
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full';
|
||||
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full bg-n-surface-1';
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -502,7 +502,7 @@ export default {
|
||||
class="flex relative flex-col"
|
||||
:class="{
|
||||
'modal-mask': isPopOutReplyBox,
|
||||
'bg-n-background': !isPopOutReplyBox,
|
||||
'bg-n-surface-1': !isPopOutReplyBox,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -29,7 +29,7 @@ defineProps({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full w-full bg-n-background border border-n-weak rounded-lg p-4 flex flex-col"
|
||||
class="h-full w-full bg-n-surface-2 border border-n-weak rounded-lg p-4 flex flex-col"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ const onClickTabChange = index => {
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<div class="flex flex-col px-8 pb-4 mt-1">
|
||||
<woot-tabs
|
||||
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
|
||||
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0 h-10"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
|
||||
@@ -34,11 +34,21 @@ function getEventPrefix(action) {
|
||||
* @param {string} action - The action type
|
||||
* @param {number} conversationId - The conversation ID
|
||||
* @param {number} [followUpCount] - Optional follow-up count
|
||||
* @param {string} [uiFrom] - Optional UI source identifier
|
||||
* @returns {Object} The payload object
|
||||
*/
|
||||
function buildPayload(action, conversationId, followUpCount = undefined) {
|
||||
function buildPayload(
|
||||
action,
|
||||
conversationId,
|
||||
followUpCount = undefined,
|
||||
uiFrom = undefined
|
||||
) {
|
||||
const payload = { conversationId };
|
||||
|
||||
if (uiFrom) {
|
||||
payload.uiFrom = uiFrom;
|
||||
}
|
||||
|
||||
// Add operation for rewrite actions
|
||||
if (REWRITE_ACTIONS.includes(action)) {
|
||||
payload.operation = action;
|
||||
@@ -75,6 +85,7 @@ export function useCopilotReply() {
|
||||
const trackedConversationId = ref(null);
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
const uiFrom = 'editor';
|
||||
|
||||
const isActive = computed(() => showEditor.value || isGenerating.value);
|
||||
const isButtonDisabled = computed(
|
||||
@@ -97,7 +108,8 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -168,7 +180,7 @@ export function useCopilotReply() {
|
||||
const eventKey = `${getEventPrefix(action)}_USED`;
|
||||
useTrack(
|
||||
CAPTAIN_EVENTS[eventKey],
|
||||
buildPayload(action, trackedConversationId.value)
|
||||
buildPayload(action, trackedConversationId.value, undefined, uiFrom)
|
||||
);
|
||||
}
|
||||
isGenerating.value = false;
|
||||
@@ -194,6 +206,7 @@ export function useCopilotReply() {
|
||||
// Track follow-up sent event
|
||||
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
|
||||
conversationId: trackedConversationId.value,
|
||||
uiFrom,
|
||||
});
|
||||
followUpCount.value += 1;
|
||||
|
||||
@@ -237,7 +250,8 @@ export function useCopilotReply() {
|
||||
buildPayload(
|
||||
currentAction.value,
|
||||
trackedConversationId.value,
|
||||
followUpCount.value
|
||||
followUpCount.value,
|
||||
uiFrom
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
|
||||
{ name: 'conversation_summary' },
|
||||
{ name: 'conversation_actions' },
|
||||
{ name: 'macros' },
|
||||
{ name: 'conversation_info' },
|
||||
|
||||
@@ -127,6 +127,7 @@ const validateSingleAction = action => {
|
||||
'resolve_conversation',
|
||||
'remove_assigned_team',
|
||||
'open_conversation',
|
||||
'pending_conversation',
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
"OPEN_CONVERSATION": "Open conversation",
|
||||
"PENDING_CONVERSATION": "Mark conversation as pending"
|
||||
},
|
||||
"MESSAGE_TYPES": {
|
||||
"INCOMING": "Incoming Message",
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"SUMMARY": {
|
||||
"TITLE": "Generate Summary",
|
||||
"LOADING": "Generating summary...",
|
||||
"ERROR": "Failed to generate summary"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +574,8 @@
|
||||
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
|
||||
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"LOAD_MORE": "Load more"
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"UNSUPPORTED_MESSAGE": "This message is unsupported. To view it, please open it on the original platform.",
|
||||
"UNSUPPORTED_MESSAGE_FACEBOOK": "This message is unsupported. You can view this message on the Facebook Messenger app.",
|
||||
"UNSUPPORTED_MESSAGE_INSTAGRAM": "This message is unsupported. You can view this message on the Instagram app.",
|
||||
"UNSUPPORTED_MESSAGE_TIKTOK": "This message is unsupported. You can view this message on the TikTok app.",
|
||||
"SUCCESS_DELETE_MESSAGE": "Message deleted successfully",
|
||||
"FAIL_DELETE_MESSSAGE": "Couldn't delete message! Try again",
|
||||
"NO_RESPONSE": "No response",
|
||||
@@ -252,6 +253,8 @@
|
||||
"MESSAGE_ERROR": "Unable to send this message, please try again later",
|
||||
"SENT_BY": "Sent by:",
|
||||
"BOT": "Bot",
|
||||
"NATIVE_APP": "Native app",
|
||||
"NATIVE_APP_ADVISORY": "This message was sent from the native app. Reply from Chatwoot to maintain the message window.",
|
||||
"SEND_FAILED": "Couldn't send message! Try again",
|
||||
"TRY_AGAIN": "retry",
|
||||
"ASSIGNMENT": {
|
||||
@@ -346,6 +349,7 @@
|
||||
"CONVERSATION_ACTIONS": "Conversation Actions",
|
||||
"CONVERSATION_LABELS": "Conversation Labels",
|
||||
"CONVERSATION_INFO": "Conversation Information",
|
||||
"CONVERSATION_SUMMARY": "AI Summary",
|
||||
"CONTACT_NOTES": "Contact Notes",
|
||||
"CONTACT_ATTRIBUTES": "Contact Attributes",
|
||||
"PREVIOUS_CONVERSATION": "Previous Conversations",
|
||||
@@ -353,6 +357,16 @@
|
||||
"LINEAR_ISSUES": "Linked Linear Issues",
|
||||
"SHOPIFY_ORDERS": "Shopify Orders"
|
||||
},
|
||||
"SUMMARY": {
|
||||
"DESCRIPTION": "Generate an AI-powered summary of this conversation",
|
||||
"GENERATE": "Generate Summary",
|
||||
"REGENERATE": "Regenerate",
|
||||
"REFRESH": "Refresh",
|
||||
"RETRY": "Retry",
|
||||
"STALE": "Summary may be outdated",
|
||||
"ERROR": "Failed to generate summary",
|
||||
"EMPTY": "No summary available"
|
||||
},
|
||||
"SHOPIFY": {
|
||||
"ORDER_ID": "Order #{id}",
|
||||
"ERROR": "Error loading orders",
|
||||
|
||||
@@ -273,7 +273,8 @@
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading...",
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available."
|
||||
"INSTAGRAM_STORY_UNAVAILABLE": "This story is no longer available.",
|
||||
"INSTAGRAM_STORY_REPLY": "Replied to your story:"
|
||||
},
|
||||
"LOCATION_BUBBLE": {
|
||||
"SEE_ON_MAP": "See on map"
|
||||
|
||||
@@ -94,7 +94,7 @@ onUnmounted(() => {
|
||||
class="icon"
|
||||
aria-hidden="true"
|
||||
:class="{
|
||||
'text-n-blue-text': isInputFocused,
|
||||
'text-n-blue-11': isInputFocused,
|
||||
'text-n-slate-10': !isInputFocused,
|
||||
}"
|
||||
/>
|
||||
|
||||
@@ -358,7 +358,7 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full bg-n-background">
|
||||
<div class="flex flex-col w-full h-full bg-n-surface-1">
|
||||
<div class="flex w-full p-4">
|
||||
<NextButton
|
||||
:label="t('GENERAL_SETTINGS.BACK')"
|
||||
|
||||
@@ -140,7 +140,9 @@ export default {
|
||||
@close-mobile-sidebar="closeMobileSidebar"
|
||||
/>
|
||||
|
||||
<main class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden">
|
||||
<main
|
||||
class="flex flex-1 h-full w-full min-h-0 px-0 overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<UpgradePage
|
||||
v-show="showUpgradePage"
|
||||
ref="upgradePageRef"
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background px-6"
|
||||
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-surface-1 px-6"
|
||||
>
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive v-if="keepAlive">
|
||||
|
||||
@@ -82,7 +82,7 @@ onMounted(() => performRouting());
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-center w-full bg-n-background text-n-slate-11"
|
||||
class="flex items-center justify-center w-full bg-n-surface-1 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,7 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="flex w-full h-full min-h-0">
|
||||
<section class="flex flex-1 h-full px-0 overflow-hidden bg-n-background">
|
||||
<section class="flex flex-1 h-full px-0 overflow-hidden bg-n-surface-1">
|
||||
<router-view />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user