Compare commits

..
Author SHA1 Message Date
Tanmay Deep Sharma 08cefb2bb8 do not send notifications for failed messages 2026-01-27 17:06:04 +05:30
270 changed files with 2696 additions and 9933 deletions
+7 -7
View File
@@ -144,7 +144,7 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 16
steps:
- checkout
- node/install:
@@ -350,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
-1
View File
@@ -276,4 +276,3 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
+2 -2
View File
@@ -541,9 +541,9 @@ GEM
net-smtp
marcel (1.0.4)
maxminddb (0.1.22)
meta_request (0.8.5)
meta_request (0.8.3)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 9)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
@@ -112,25 +112,6 @@ 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
@@ -158,7 +139,6 @@ 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,
@@ -167,7 +147,6 @@ 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
@@ -1,68 +0,0 @@
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
@@ -1,65 +0,0 @@
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
@@ -1,79 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -28,7 +28,8 @@ 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_with_has_more(contacts)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -141,24 +142,6 @@ 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,10 +70,8 @@ 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,9 +35,12 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests unless conversation.present? && conversation.account.within_email_rate_limit?
send_transcript_email
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation,
conversation.contact.email
)&.deliver_later
end
head :ok
end
@@ -74,16 +77,6 @@ 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,31 +62,6 @@ 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
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -164,28 +139,4 @@ 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
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
-7
View File
@@ -1,7 +0,0 @@
# 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' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'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_API_VERSION],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'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 WIDGET_TOKEN_EXPIRY]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
end
end
+1 -1
View File
@@ -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 bg-n-background"
class="flex flex-col w-full h-screen min-h-0"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -107,39 +107,21 @@
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--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;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 226 227 231;
--border-strong: 235 235 235;
--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: 255 228 181;
--solid-amber: 252 232 193;
--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: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -253,43 +235,25 @@
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--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;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 56 50 41;
--solid-blue: 15 57 102;
--solid-blue-2: 26 29 35;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--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;
--text-blue: 126 182 255;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
--alpha-3: 33, 34, 38, 0.95;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--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: 255, 255, 255, 0;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
@apply text-n-blue-text;
}
}
}
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
class="[&>button]:!text-n-blue-text [&>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-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<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">
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text 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-surface-1"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<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-surface-1"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-11"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -5,7 +5,6 @@ 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: '' },
@@ -20,9 +19,6 @@ 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([
@@ -31,7 +27,6 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -66,19 +61,11 @@ 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-surface-1"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -107,14 +94,9 @@ const showPagination = computed(() => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -1,28 +0,0 @@
<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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text 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-11':
'truncate hover:text-n-brand text-n-blue-text':
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-surface-1 from-25% to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
@@ -48,12 +48,14 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2 class="text-3xl font-medium text-center text-n-slate-12">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay 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-11 text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text 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-11 text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text 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-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<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-11 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-text px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text 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-11'] =
const [icon = '', color = 'text-n-blue-text'] =
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-11'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
>
{{ 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-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-11 outline-n-brand',
'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',
ghost:
'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',
'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',
},
ruby: {
solid:
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'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',
'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',
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-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
@@ -169,7 +169,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -18,9 +18,7 @@ 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');
@@ -39,7 +37,7 @@ defineExpose({ dialogRef });
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@@ -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-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -116,7 +116,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text 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-card 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-background 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-11' }),
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
@@ -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-11`,
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
}),
};
return acc;
@@ -3,14 +3,12 @@ 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,
@@ -141,8 +139,6 @@ 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
@@ -166,10 +162,6 @@ 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;
@@ -311,7 +303,6 @@ 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)) {
@@ -432,18 +423,6 @@ 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 {
@@ -471,9 +450,6 @@ 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}`;
});
@@ -507,7 +483,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full mb-2 message-bubble-container"
class="flex mb-2 w-full 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-surface-1">
<ul class="px-4 bg-n-background">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
@@ -7,7 +7,6 @@ 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';
@@ -81,7 +80,7 @@ const replyToPreview = computed(() => {
const { content, attachments } = inReplyTo.value;
if (content) return new MessageFormatter(content).formattedMessage;
if (content) return content;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -108,10 +107,9 @@ const replyToPreview = computed(() => {
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
</div>
<slot />
<MessageMeta
@@ -1,26 +1,19 @@
<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, ATTACHMENT_TYPES } from '../constants';
import { MESSAGE_VARIANTS } from '../constants';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const { variant, content, 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);
@@ -45,9 +38,6 @@ 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-11 w-full" />
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -18,8 +18,12 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<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" />
<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"
/>
</div>
</div>
</template>
@@ -1,27 +1,9 @@
<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(unsupportedMessageKey) }}
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</BaseBubble>
</template>
@@ -52,7 +52,6 @@ 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-surface-1 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-background 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,7 +8,6 @@ const props = defineProps({
type: String,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
active: {
type: Boolean,
default: false,
@@ -25,7 +24,10 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<span
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<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-out block md:hidden"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-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, useSidebarResize } from './provider';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
@@ -8,7 +8,6 @@ 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';
@@ -16,9 +15,7 @@ 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';
@@ -45,10 +42,6 @@ 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) {
@@ -65,85 +58,11 @@ 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');
@@ -273,7 +192,6 @@ 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, {
@@ -292,7 +210,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[8px] rounded-sm`,
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
@@ -420,7 +338,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[8px] rounded-sm`,
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
@@ -686,56 +604,32 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
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="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="[
{
'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"
: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>
<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')"
/>
</div>
<div
class="flex gap-2"
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
>
<div class="flex gap-2 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-button-color transition-all duration-100 ease-out"
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"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
<span class="flex-grow text-start text-n-slate-10">
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
@@ -744,41 +638,21 @@ 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, isOpen }">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
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 },
]"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
</div>
</section>
<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 }"
>
<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">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -790,43 +664,18 @@ 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-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<SidebarChangelogCard
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
!isEffectivelyCollapsed
"
/>
<SidebarChangelogButton
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
isEffectivelyCollapsed
"
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
<div
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'"
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)]"
>
<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,7 +5,6 @@ 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,
@@ -14,13 +13,6 @@ import {
DropdownItem,
} from 'next/dropdown-menu/base';
defineProps({
isCollapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['showCreateAccountModal']);
const { t } = useI18n();
@@ -53,19 +45,7 @@ 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"
@@ -93,10 +73,7 @@ const emitNewAccount = () => {
/>
</button>
</template>
<DropdownBody
v-if="showAccountSwitcher || isCollapsed"
class="min-w-80 z-50"
>
<DropdownBody v-if="showAccountSwitcher" class="min-w-80 z-50">
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
<DropdownItem
v-for="account in sortedCurrentUserAccounts"
@@ -1,46 +0,0 @@
<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,10 +4,6 @@ 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();
@@ -94,11 +90,6 @@ const handleImgClick = ({ index }) => {
handleReadMore();
};
defineExpose({
isLoading,
unDismissedPosts,
});
onMounted(() => {
fetchChangelog();
});
@@ -107,7 +98,6 @@ onMounted(() => {
<template>
<GroupedStackedChangelogCard
v-if="unDismissedPosts.length > 0"
v-bind="$attrs"
:posts="unDismissedPosts"
:current-index="currentIndex"
:dismissing-slugs="dismissingCards"
@@ -1,198 +0,0 @@
<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,14 +1,12 @@
<script setup>
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
import { useSidebarContext, usePopoverState } from './provider';
import { computed, onMounted, watch, nextTick } from 'vue';
import { useSidebarContext } 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 },
@@ -27,18 +25,8 @@ 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) || [];
});
@@ -51,54 +39,6 @@ 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 => {
@@ -167,13 +107,6 @@ 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 &&
@@ -192,13 +125,6 @@ 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(
@@ -219,82 +145,45 @@ watch(
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
class="grid gap-1 text-sm cursor-pointer select-none"
>
<!-- 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"
<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"
:active-child="activeChild"
:trigger-rect="triggerRect"
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
/>
</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>
<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>
</Policy>
</template>
@@ -26,13 +26,13 @@ const count = computed(() =>
<template>
<component
:is="to ? 'router-link' : 'div'"
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
role="button"
draggable="false"
:to="to"
:title="label"
:class="{
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
'text-n-blue-text 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,21 +45,15 @@ 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 flex-1">
<span
class="truncate"
:class="{
'text-body-main': !isActive,
'font-medium text-sm': isActive || hasActiveChild,
}"
>
<div class="flex items-center gap-1.5 flex-grow min-w-0">
<span class="text-sm font-medium leading-5 truncate">
{{ 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-slate-12 outline-n-slate-6': isActive,
'text-n-blue-text 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-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"
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"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
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="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="{
'text-n-slate-12 bg-n-alpha-2 active': active,
'text-n-blue-text bg-n-alpha-2 active': active,
}"
>
<component
@@ -15,10 +15,6 @@ import {
} from 'next/dropdown-menu/base';
import CustomBrandPolicyWrapper from '../../components/CustomBrandPolicyWrapper.vue';
defineProps({
isCollapsed: { type: Boolean, default: false },
});
const emit = defineEmits(['close', 'openKeyShortcutModal']);
defineOptions({
@@ -124,19 +120,11 @@ const allowedMenuItems = computed(() => {
</script>
<template>
<DropdownContainer
class="relative min-w-0"
:class="isCollapsed ? 'w-auto' : 'w-full'"
@close="emit('close')"
>
<DropdownContainer class="relative w-full min-w-0" @close="emit('close')">
<template #trigger="{ toggle, isOpen }">
<button
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"
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 }"
@click="toggle"
>
<Avatar
@@ -147,7 +135,7 @@ const allowedMenuItems = computed(() => {
class="flex-shrink-0"
rounded-full
/>
<div v-if="!isCollapsed" class="min-w-0">
<div class="min-w-0">
<div class="text-sm font-medium leading-4 truncate text-n-slate-12">
{{ currentUser.available_name }}
</div>
@@ -48,15 +48,11 @@ useEventListener(scrollableContainer, 'scroll', () => {
:icon
class="my-1"
/>
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
>
<ul v-if="children.length" class="m-0 list-none reset-base relative group">
<!-- 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,
}"
@@ -72,7 +68,7 @@ useEventListener(scrollableContainer, 'scroll', () => {
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
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"
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"
>
<svg
width="16"
@@ -1,87 +1,9 @@
import { inject, provide, ref, computed } from 'vue';
import { inject, provide } 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) {
@@ -89,6 +11,7 @@ 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-out"
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="
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-11 scale-100'
? 'text-n-blue-text 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-11 cursor-pointer">
<div class="flex justify-end w-3 text-n-blue-text 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="outline outline-1 outline-n-weak -mt-[-1px] border-t-0 rounded-br-lg rounded-bl-lg"
class="bg-n-background 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 conversations-list-wrap bg-n-surface-1"
class="flex flex-col flex-shrink-0 bg-n-solid-1 conversations-list-wrap"
: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-[3.25rem]"
class="flex items-center justify-between gap-2 px-3 h-12"
:class="{
'border-b border-n-strong': hasAppliedFiltersOrActiveFolders,
}"
@@ -133,7 +133,7 @@ onMounted(() => {
<div
v-if="shouldShowCopilotPanel"
v-on-click-outside="() => closeCopilotPanel()"
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="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="[
{
'md:flex': shouldShowCopilotPanel,
@@ -1,14 +1,11 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
isNavigableRange,
getRangeAtOffset,
} from './helpers/DatePickerHelper';
import {
isValid,
@@ -16,14 +13,14 @@ import {
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
differenceInCalendarWeeks,
setMonth,
setYear,
getWeek,
isAfter,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
@@ -35,187 +32,98 @@ import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const emit = defineEmits(['dateRangeChanged']);
const { t } = useI18n();
const dateRange = defineModel('dateRange', {
type: Array,
default: undefined,
});
const rangeType = defineModel('rangeType', {
type: String,
default: undefined,
});
const { LAST_7_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
// Use dates from v-model if provided, otherwise default to last 7 days
const selectedStartDate = ref(
dateRange.value?.[0]
? startOfDay(dateRange.value[0])
: startOfDay(subDays(currentDate.value, 6)) // LAST_7_DAYS
);
const selectedEndDate = ref(
dateRange.value?.[1]
? endOfDay(dateRange.value[1])
: endOfDay(currentDate.value)
);
// Calendar month positioning (left and right calendars)
// These control which months are displayed in the dual calendar view
const startCurrentDate = ref(startOfMonth(selectedStartDate.value));
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Same month: show next month on right (e.g., Jan 25-31 shows Jan + Feb)
: startOfMonth(selectedEndDate.value) // Different months: show end month on right (e.g., Dec 5 - Jan 3 shows Dec + Jan)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(rangeType.value || LAST_7_DAYS);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const monthOffset = ref(0);
const showMonthNavigation = computed(() =>
isNavigableRange(selectedRange.value)
);
const canNavigateNext = computed(() => {
if (!isNavigableRange(selectedRange.value)) return false;
// Compare selected start to the current period's start to determine if we're in the past
const currentRange = getActiveDateRange(
selectedRange.value,
currentDate.value
);
return selectedStartDate.value < currentRange.start;
});
const navigationLabel = computed(() => {
const range = selectedRange.value;
if (range === DATE_RANGE_TYPES.MONTH_TO_DATE) {
return new Intl.DateTimeFormat(navigator.language, {
month: 'long',
}).format(selectedStartDate.value);
}
if (range === DATE_RANGE_TYPES.THIS_WEEK) {
const currentWeekRange = getActiveDateRange(range, currentDate.value);
const isCurrentWeek =
selectedStartDate.value.getTime() === currentWeekRange.start.getTime();
if (isCurrentWeek) return null;
const weekNumber = getWeek(selectedStartDate.value, { weekStartsOn: 1 });
return t('DATE_PICKER.WEEK_NUMBER', { weekNumber });
}
return null;
});
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
// Watcher 1: Sync v-model props from parent component
// Handles: URL params, parent component updates, rangeType changes
watch(
[rangeType, dateRange],
([newRangeType, newDateRange]) => {
if (newRangeType && newRangeType !== selectedRange.value) {
selectedRange.value = newRangeType;
monthOffset.value = 0;
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// If rangeType changes without dateRange, recompute dates from the range
if (!newDateRange && newRangeType !== CUSTOM_RANGE) {
const activeDates = getActiveDateRange(newRangeType, currentDate.value);
if (activeDates) {
selectedStartDate.value = startOfDay(activeDates.start);
selectedEndDate.value = endOfDay(activeDates.end);
}
}
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
// When parent provides new dateRange (e.g., from URL params)
// Skip if navigating with arrows — offset controls dates in that case
if (newDateRange?.[0] && newDateRange?.[1] && monthOffset.value === 0) {
selectedStartDate.value = startOfDay(newDateRange[0]);
selectedEndDate.value = endOfDay(newDateRange[1]);
// Update calendar to show the months of the new date range
startCurrentDate.value = startOfMonth(newDateRange[0]);
endCurrentDate.value = isSameMonth(newDateRange[0], newDateRange[1])
? startOfMonth(addMonths(newDateRange[1], 1))
: startOfMonth(newDateRange[1]);
// Recalculate offset so arrow navigation is relative to restored range
// TODO: When offset resolves to 0 (current period), the end date may be
// stale if the URL was saved on a previous day. "This month" / "This week"
// should show up-to-today dates for the current period. For now, the stale
// end date is shown until the user clicks an arrow or re-selects the range.
if (isNavigableRange(selectedRange.value)) {
const current = getActiveDateRange(
selectedRange.value,
currentDate.value
);
if (selectedRange.value === DATE_RANGE_TYPES.THIS_WEEK) {
monthOffset.value = differenceInCalendarWeeks(
newDateRange[0],
current.start,
{ weekStartsOn: 1 }
);
} else {
monthOffset.value = differenceInCalendarMonths(
newDateRange[0],
current.start
);
}
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher 2: Keep manual input fields in sync with selected dates
// Updates the input field values when dates change programmatically
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
manualStartDate.value = isValid(newStart)
? newStart
: selectedStartDate.value;
manualEndDate.value = isValid(newEnd) ? newEnd : selectedEndDate.value;
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true }
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
monthOffset.value = 0;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
// Position calendar to show the months of the selected range
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
};
const navigateMonth = direction => {
monthOffset.value += direction === 'prev' ? -1 : 1;
if (monthOffset.value > 0) monthOffset.value = 0;
const { start, end } = getRangeAtOffset(
selectedRange.value,
monthOffset.value,
currentDate.value
);
selectedStartDate.value = start;
selectedEndDate.value = end;
startCurrentDate.value = startOfMonth(start);
endCurrentDate.value = isSameMonth(start, end)
? startOfMonth(addMonths(start, 1))
: startOfMonth(end);
emit('dateRangeChanged', [start, end, selectedRange.value]);
};
const moveCalendar = (calendar, direction, period = MONTH) => {
@@ -226,27 +134,12 @@ const moveCalendar = (calendar, direction, period = MONTH) => {
direction,
period
);
// Prevent calendar months from overlapping
const monthDiff = differenceInCalendarMonths(end, start);
if (monthDiff === 0) {
// If they would be the same month, adjust the other calendar
if (calendar === START_CALENDAR) {
endCurrentDate.value = addMonths(start, 1);
startCurrentDate.value = start;
} else {
startCurrentDate.value = subMonths(end, 1);
endCurrentDate.value = end;
}
} else {
startCurrentDate.value = start;
endCurrentDate.value = end;
}
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
monthOffset.value = 0;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
@@ -282,10 +175,10 @@ const openCalendar = (index, calendarType, period = MONTH) => {
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = startOfMonth(newDate);
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = startOfMonth(newDate);
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
@@ -295,22 +188,13 @@ const handleManualInputError = message => {
};
const resetDatePicker = () => {
// Calculate Last 7 days from today
const startDate = startOfDay(subDays(currentDate.value, 6));
const endDate = endOfDay(currentDate.value);
selectedStartDate.value = startDate;
selectedEndDate.value = endDate;
// Position calendar to show the months of Last 7 days
// Example: If today is Feb 5, Last 7 days = Jan 30 - Feb 5, so show Jan + Feb
startCurrentDate.value = startOfMonth(startDate);
endCurrentDate.value = isSameMonth(startDate, endDate)
? startOfMonth(addMonths(startDate, 1))
: startOfMonth(endDate);
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
monthOffset.value = 0;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
@@ -319,58 +203,26 @@ const emitDateRange = () => {
useAlert('Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [
selectedStartDate.value,
selectedEndDate.value,
selectedRange.value,
]);
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
// Called when picker opens - positions calendar to show selected date range
// Fixes issue where calendar showed wrong months when loaded from URL params
const initializeCalendarMonths = () => {
if (selectedStartDate.value && selectedEndDate.value) {
startCurrentDate.value = startOfMonth(selectedStartDate.value);
endCurrentDate.value = isSameMonth(
selectedStartDate.value,
selectedEndDate.value
)
? startOfMonth(addMonths(selectedEndDate.value, 1))
: startOfMonth(selectedEndDate.value);
}
};
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value;
if (showDatePicker.value) initializeCalendarMonths();
};
const closeDatePicker = () => {
if (isValid(selectedStartDate.value) && isValid(selectedEndDate.value)) {
emitDateRange();
} else {
showDatePicker.value = false;
}
showDatePicker.value = false;
};
</script>
<template>
<div class="relative flex-shrink-0 font-inter">
<div v-on-clickaway="closeDatePicker" class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
:show-month-navigation="showMonthNavigation"
:can-navigate-next="canNavigateNext"
:navigation-label="navigationLabel"
@open="toggleDatePicker"
@navigate-month="navigateMonth"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
v-on-clickaway="closeDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container"
>
<CalendarDateRange
:selected-range="selectedRange"
@@ -46,13 +46,13 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click.stop="onClickPrev(calendarType)"
@click="onClickPrev(calendarType)"
/>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12 hover:text-n-brand"
@click.stop="onClickSetView(calendarType, viewMode)"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
@@ -60,7 +60,7 @@ const onClickSetView = (type, mode) => {
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-n-slate-12"
:class="{ 'hover:text-n-brand': viewMode }"
@click.stop="onClickSetView(calendarType, YEAR)"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
@@ -71,7 +71,7 @@ const onClickSetView = (type, mode) => {
xs
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
@click.stop="onClickNext(calendarType)"
@click="onClickNext(calendarType)"
/>
</div>
</template>
@@ -18,25 +18,24 @@ const setDateRange = range => {
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-xs font-bold capitalize text-start text-n-slate-10"
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-n-slate-12"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<template v-for="range in dateRanges" :key="range.label">
<div v-if="range.separator" class="w-full border-t border-n-strong" />
<button
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</template>
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-n-alpha-2 dark:hover:bg-n-solid-3"
:class="
range.value === selectedRange
? 'text-n-slate-12 bg-n-alpha-1 dark:bg-n-solid-active'
: 'text-n-slate-12'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>
@@ -23,6 +23,7 @@ const onClickApply = () => {
/>
<NextButton
sm
ghost
:label="$t('DATE_PICKER.APPLY_BUTTON')"
@click="onClickApply"
/>
@@ -78,7 +78,7 @@ const selectMonth = index => {
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3':
index !== activeMonthIndex,
}"
@click.stop="selectMonth(index)"
@click="selectMonth(index)"
>
{{ month }}
</button>
@@ -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-11':
'outline outline-1 outline-n-blue-8 -outline-offset-1 !text-n-blue-text':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
@@ -77,7 +77,7 @@ const selectYear = year => {
'bg-n-brand text-white hover:bg-n-blue-10': year === activeYear,
'hover:bg-n-alpha-2 dark:hover:bg-n-solid-3': year !== activeYear,
}"
@click.stop="selectYear(year)"
@click="selectYear(year)"
>
{{ year }}
</button>
@@ -2,8 +2,6 @@
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
selectedStartDate: Date,
@@ -12,21 +10,9 @@ const props = defineProps({
type: String,
default: '',
},
showMonthNavigation: {
type: Boolean,
default: false,
},
canNavigateNext: {
type: Boolean,
default: false,
},
navigationLabel: {
type: String,
default: null,
},
});
const emit = defineEmits(['open', 'navigateMonth']);
const emit = defineEmits(['open']);
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
@@ -36,15 +22,19 @@ const formatDateRange = computed(() => {
return 'Select a date range';
}
const crossesYears = !isSameYear(startDate, endDate);
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
// Always show years when crossing year boundaries
if (crossesYears) {
return `${format(startDate, 'MMM d, yyyy')} - ${format(endDate, 'MMM d, yyyy')}`;
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
}
// For same year, always show the year for clarity
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d, yyyy')}`;
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
});
const activeDateRange = computed(
@@ -57,46 +47,17 @@ const openDatePicker = () => {
</script>
<template>
<div class="inline-flex items-center gap-1">
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1 flex-shrink-0"
@click="openDatePicker"
>
<Icon
icon="i-lucide-calendar-range"
class="text-n-slate-11 size-3.5 flex-shrink-0"
/>
<span class="text-sm font-medium text-n-slate-12 truncate">
{{ navigationLabel || $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11 truncate">
{{ formatDateRange }}
</span>
<Icon
icon="i-lucide-chevron-down"
class="text-n-slate-12 size-4 flex-shrink-0"
/>
</button>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.PREVIOUS_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-left"
class="rtl:rotate-180"
@click="emit('navigateMonth', 'prev')"
/>
<NextButton
v-if="showMonthNavigation"
v-tooltip.top="$t('DATE_PICKER.NEXT_PERIOD')"
slate
faded
sm
icon="i-lucide-chevron-right"
class="rtl:rotate-180"
:disabled="!canNavigateNext"
@click="emit('navigateMonth', 'next')"
/>
</div>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-n-alpha-2 hover:bg-n-alpha-1 active:bg-n-alpha-1"
@click="openDatePicker"
>
<fluent-icon class="text-n-slate-12" icon="calendar" size="16" />
<span class="text-sm font-medium text-n-slate-12">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-n-slate-11">
{{ formatDateRange }}
</span>
<fluent-icon class="text-n-slate-12" icon="chevron-down" size="14" />
</button>
</template>
@@ -10,8 +10,6 @@ import {
isSameMonth,
format,
startOfWeek,
endOfWeek,
addWeeks,
addDays,
eachDayOfInterval,
endOfMonth,
@@ -36,27 +34,13 @@ export const dateRanges = [
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.THIS_WEEK',
value: 'thisWeek',
separator: true,
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.MONTH_TO_DATE',
value: 'monthToDate',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE',
value: 'custom',
separator: true,
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
];
export const DATE_RANGE_TYPES = {
@@ -65,8 +49,6 @@ export const DATE_RANGE_TYPES = {
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
THIS_WEEK: 'thisWeek',
MONTH_TO_DATE: 'monthToDate',
CUSTOM_RANGE: 'custom',
};
@@ -228,14 +210,6 @@ export const getActiveDateRange = (range, currentDate) => {
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
thisWeek: () => ({
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: 1 })),
end: endOfDay(currentDate),
}),
monthToDate: () => ({
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
@@ -243,48 +217,3 @@ export const getActiveDateRange = (range, currentDate) => {
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
export const isNavigableRange = rangeType =>
rangeType === DATE_RANGE_TYPES.MONTH_TO_DATE ||
rangeType === DATE_RANGE_TYPES.THIS_WEEK;
const WEEK_START = 1; // Monday
const getWeekRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfWeek(currentDate, { weekStartsOn: WEEK_START })),
end: endOfDay(currentDate),
};
}
const targetWeek = addWeeks(currentDate, offset);
return {
start: startOfDay(startOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
end: endOfDay(endOfWeek(targetWeek, { weekStartsOn: WEEK_START })),
};
};
const getMonthRangeAtOffset = (offset, currentDate) => {
if (offset === 0) {
return {
start: startOfDay(startOfMonth(currentDate)),
end: endOfDay(currentDate),
};
}
const targetMonth = addMonths(currentDate, offset);
return {
start: startOfDay(startOfMonth(targetMonth)),
end: endOfDay(endOfMonth(targetMonth)),
};
};
export const getRangeAtOffset = (
rangeType,
offset,
currentDate = new Date()
) => {
if (rangeType === DATE_RANGE_TYPES.THIS_WEEK) {
return getWeekRangeAtOffset(offset, currentDate);
}
return getMonthRangeAtOffset(offset, currentDate);
};
@@ -34,8 +34,8 @@ const value = defineModel({
<input
v-model="value"
:placeholder="inputPlaceholder"
type="search"
class="w-full mb-0 text-sm !outline-0 !outline-none bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
type="text"
class="w-full mb-0 text-sm !outline-0 bg-transparent text-n-slate-12 placeholder:text-n-slate-10 reset-base"
/>
</div>
<!-- Clear filter button -->
@@ -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 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="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="[
active
? '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',
? 'border-b border-n-brand text-n-blue-text'
: 'border-transparent text-n-slate-11',
isCompact ? 'py-2 text-sm' : 'text-base py-3',
]"
@click="onTabClick"
>
{{ name }}
<div
v-if="showBadge"
class="rounded-full h-5 flex items-center justify-center text-xs font-medium my-0 ltr:ml-1 rtl:mr-1 px-1.5 py-0 min-w-[20px]"
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="[
active
? 'bg-n-blue-3 text-n-blue-11'
: 'bg-n-alpha-1 text-n-slate-10',
? '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',
]"
>
<span>
@@ -48,7 +48,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<woot-tabs
:index="activeTabIndex"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0 h-10"
class="w-full px-3 -mt-1 py-0 [&_ul]:p-0"
@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-surface-1 relative"
class="conversation-details-wrap flex flex-col min-w-0 w-full bg-n-background 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="h-10"
class="-mt-px border-t border-t-n-background"
@change="onDashboardAppTabChange"
>
<woot-tabs-item
@@ -114,6 +114,7 @@ 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">
@@ -236,8 +236,9 @@ 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-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'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,
'px-0': compact,
'px-3': !compact,
}"
@@ -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 pt-3 pb-2 h-24 xl:h-12"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
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-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="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="[
{
'md:flex': activeTab === 0,
@@ -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 bg-n-surface-1';
return 'flex-1 min-w-0 px-0 flex flex-col items-center justify-center h-full';
},
},
};
@@ -502,7 +502,7 @@ export default {
class="flex relative flex-col"
:class="{
'modal-mask': isPopOutReplyBox,
'bg-n-surface-1': !isPopOutReplyBox,
'bg-n-background': !isPopOutReplyBox,
}"
>
<div
@@ -29,7 +29,7 @@ defineProps({
<template>
<div
class="h-full w-full bg-n-surface-2 border border-n-weak rounded-lg p-4 flex flex-col"
class="h-full w-full bg-n-background 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" />
@@ -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 h-10"
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
@@ -127,7 +127,6 @@ const validateSingleAction = action => {
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
@@ -150,8 +150,7 @@
"ADD_PRIVATE_NOTE": "Add a Private Note",
"CHANGE_PRIORITY": "Change Priority",
"ADD_SLA": "Add SLA",
"OPEN_CONVERSATION": "Open conversation",
"PENDING_CONVERSATION": "Mark conversation as pending"
"OPEN_CONVERSATION": "Open conversation"
},
"MESSAGE_TYPES": {
"INCOMING": "Incoming Message",
@@ -574,8 +574,7 @@
"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,7 +61,6 @@
"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",
@@ -253,8 +252,6 @@
"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": {
@@ -1,8 +1,5 @@
{
"DATE_PICKER": {
"PREVIOUS_PERIOD": "Previous period",
"NEXT_PERIOD": "Next period",
"WEEK_NUMBER": "Week #{weekNumber}",
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
@@ -16,8 +13,6 @@
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"THIS_WEEK": "This week",
"MONTH_TO_DATE": "This month",
"CUSTOM_RANGE": "Custom date range"
}
}

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