Compare commits

..
516 changed files with 11499 additions and 20602 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
-3
View File
@@ -94,8 +94,6 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
.claude/
CLAUDE.local.md
# Histoire deployment
@@ -103,4 +101,3 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
-16
View File
@@ -4,11 +4,6 @@
- **Setup**: `bundle install && pnpm install`
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
- **Seed Local Test Data**: `bundle exec rails db:seed` (quickly populates minimal data for standard feature verification)
- **Seed Search Test Data**: `bundle exec rails search:setup_test_data` (bulk fixture generation for search/performance/manual load scenarios)
- **Seed Account Sample Data (richer test data)**: `Seeders::AccountSeeder` is available as an internal utility and is exposed through Super Admin `Accounts#seed`, but can be used directly in dev workflows too:
- UI path: Super Admin → Accounts → Seed (enqueues `Internal::SeedAccountJob`).
- CLI path: `bundle exec rails runner "Internal::SeedAccountJob.perform_now(Account.find(<id>))"` (or call `Seeders::AccountSeeder.new(account: Account.find(<id>)).perform!` directly).
- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
- **Lint Ruby**: `bundle exec rubocop -a`
- **Test JS**: `pnpm test` or `pnpm test:watch`
@@ -55,13 +50,6 @@
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
- Specs in parallel/reloading environments: prefer comparing `error.class.name` over constant class equality when asserting raised errors
## Codex Worktree Workflow
- Use a separate git worktree + branch per task to keep changes isolated.
- Keep Codex-specific local setup under `.codex/` and use `Procfile.worktree` for worktree process orchestration.
- The setup workflow in `.codex/environments/environment.toml` should dynamically generate per-worktree DB/port values (Rails, Vite, Redis DB index) to avoid collisions.
- Start each worktree with its own Overmind socket/title so multiple instances can run at the same time.
## Commit Messages
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
@@ -98,7 +86,3 @@ Practical checklist for any change impacting core logic or public APIs
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
- Tests: Add Enterprise-specific specs under `spec/enterprise`, mirroring OSS spec layout where applicable.
- When modifying existing OSS features for Enterprise-only behavior, add an Enterprise module (via `prepend_mod_with`/`include_mod_with`) instead of editing OSS files directly—especially for policies, controllers, and services. For Enterprise-exclusive features, place code directly under `enterprise/`.
## Branding / White-labeling note
- For user-facing strings that currently contain "Chatwoot" but should adapt to branded/self-hosted installs, prefer applying `replaceInstallationName` from `shared/composables/useBranding` in the UI layer (for example tooltip and suggestion labels) instead of adding hardcoded brand-specific copy.
+1 -3
View File
@@ -191,14 +191,12 @@ gem 'reverse_markdown'
gem 'iso-639'
gem 'ruby-openai'
gem 'ai-agents', '>= 0.9.1'
gem 'ai-agents', '>= 0.7.0'
# TODO: Move this gem as a dependency of ai-agents
gem 'ruby_llm', '>= 1.8.2'
gem 'ruby_llm-schema'
gem 'cld3', '~> 3.7'
# OpenTelemetry for LLM observability
gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
+22 -24
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.1)
ruby_llm (~> 1.9.1)
ai-agents (0.7.0)
ruby_llm (~> 1.8.2)
annotaterb (4.20.0)
activerecord (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -186,7 +186,6 @@ GEM
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
cld3 (3.7.0)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -298,7 +297,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.1)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -309,12 +308,12 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.2)
net-http (~> 0.5)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.4.0)
faraday-retry (2.2.1)
faraday (~> 2.0)
faraday_middleware-aws-sigv4 (1.0.1)
aws-sigv4 (~> 1.0)
@@ -465,7 +464,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.18.1)
json (2.13.2)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -540,11 +539,11 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.1.0)
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)
@@ -559,12 +558,12 @@ GEM
multi_json (1.15.0)
multi_xml (0.8.0)
bigdecimal (>= 3.1, < 5)
multipart-post (2.4.1)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.9.1)
uri (>= 0.11.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.20)
@@ -583,14 +582,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.19.1)
nokogiri (1.18.9)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin)
nokogiri (1.18.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-darwin)
nokogiri (1.18.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu)
nokogiri (1.18.9-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -677,7 +676,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.5)
rack (3.2.3)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -825,7 +824,7 @@ GEM
ruby2ruby (2.5.0)
ruby_parser (~> 3.1)
sexp_processor (~> 4.6)
ruby_llm (1.9.2)
ruby_llm (1.8.2)
base64
event_stream_parser (~> 1)
faraday (>= 1.10.0)
@@ -969,7 +968,7 @@ GEM
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
uri (1.1.1)
uri (1.0.4)
uri_template (0.7.0)
valid_email2 (5.2.6)
activemodel (>= 3.2)
@@ -1004,7 +1003,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.7.4)
zeitwerk (2.6.17)
PLATFORMS
arm64-darwin-20
@@ -1024,7 +1023,7 @@ DEPENDENCIES
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
ai-agents (>= 0.9.1)
ai-agents (>= 0.7.0)
annotaterb
attr_extras
audited (~> 5.4, >= 5.4.1)
@@ -1038,7 +1037,6 @@ DEPENDENCIES
bullet
bundle-audit
byebug
cld3 (~> 3.7)
climate_control
commonmarker
csv-safe
+1 -1
View File
@@ -1 +1 @@
4.11.1
4.10.1
@@ -158,7 +158,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 +166,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,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
@@ -15,7 +15,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def meta
result = conversation_finder.perform_meta_only
result = conversation_finder.perform
@conversations_count = result[:count]
end
@@ -70,11 +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 render_payment_required('Email transcript is not available on your plan') unless @conversation.account.email_transcript_enabled?
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
@@ -1,7 +1,6 @@
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
before_action :fetch_inbox
before_action :validate_whatsapp_channel
before_action :validate_captain_enabled, only: [:analyze]
def show
service = CsatTemplateManagementService.new(@inbox)
@@ -25,23 +24,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
def analyze
template_params = extract_template_params
return render_missing_message_error if template_params[:message].blank?
result = CsatTemplateUtilityAnalysisService.new(
account: Current.account,
inbox: @inbox,
message: template_params[:message],
button_text: template_params[:button_text],
language: template_params[:language]
).perform
render json: result
rescue ActionController::ParameterMissing
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -64,12 +46,6 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
render json: { error: 'Message is required' }, status: :unprocessable_entity
end
def validate_captain_enabled
return if Current.account.feature_enabled?('captain_integration')
render json: { error: 'Captain is required for template analysis' }, status: :forbidden
end
def render_template_creation_result(result)
if result[:success]
render_successful_template_creation(result)
@@ -35,11 +35,12 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def transcript
return head :too_many_requests if conversation.blank?
return head :payment_required unless conversation.account.email_transcript_enabled?
return head :too_many_requests unless 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
@@ -76,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
@@ -70,23 +70,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
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)
@@ -173,19 +156,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
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
@@ -6,8 +6,12 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def create
@user = User.from_email(params[:email])
@user&.send_reset_password_instructions
build_response(I18n.t('messages.reset_password'), 200)
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
@@ -42,7 +42,7 @@ 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],
@@ -1,35 +0,0 @@
class Webhooks::ShopifyController < ActionController::API
before_action :verify_hmac!
def events
case request.headers['X-Shopify-Topic']
when 'shop/redact'
handle_shop_redact
end
head :ok
end
private
def verify_hmac!
secret = GlobalConfigService.load('SHOPIFY_CLIENT_SECRET', nil)
return head :unauthorized if secret.blank?
data = request.body.read
request.body.rewind
hmac_header = request.headers['X-Shopify-Hmac-SHA256']
return head :unauthorized if hmac_header.blank?
computed = Base64.strict_encode64(OpenSSL::HMAC.digest('SHA256', secret, data))
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed, hmac_header)
end
def handle_shop_redact
shop_domain = params[:shop_domain]
return if shop_domain.blank?
Integrations::Hook.where(app_id: 'shopify', reference_id: shop_domain).destroy_all
end
end
-16
View File
@@ -55,22 +55,6 @@ class ConversationFinder
}
end
def perform_meta_only
set_up
mine_count, unassigned_count, all_count, = set_count_for_all_conversations
assigned_count = all_count - unassigned_count
{
count: {
mine_count: mine_count,
assigned_count: assigned_count,
unassigned_count: unassigned_count,
all_count: all_count
}
}
end
private
def set_up
+21 -17
View File
@@ -57,35 +57,39 @@ module Api::V1::InboxesHelper
end
def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish
rescue Net::SMTPAuthenticationError
raise StandardError, I18n.t('errors.inboxes.smtp.authentication_error')
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout
raise StandardError, I18n.t('errors.inboxes.smtp.connection_error')
rescue OpenSSL::SSL::SSLError
raise StandardError, I18n.t('errors.inboxes.smtp.ssl_error')
rescue Net::SMTPServerBusy, Net::SMTPSyntaxError, Net::SMTPFatalError
raise StandardError, I18n.t('errors.inboxes.smtp.smtp_error')
rescue StandardError => e
raise StandardError, e.message
end
def set_smtp_encryption(channel_data, smtp)
if channel_data[:smtp_enable_ssl_tls]
set_smtp_ssl_method(smtp, :enable_tls, channel_data[:smtp_openssl_verify_mode])
set_enable_tls(channel_data, smtp)
elsif channel_data[:smtp_enable_starttls_auto]
set_smtp_ssl_method(smtp, :enable_starttls_auto, channel_data[:smtp_openssl_verify_mode])
set_enable_starttls_auto(channel_data, smtp)
end
end
def set_smtp_ssl_method(smtp, method, openssl_verify_mode)
return unless smtp.respond_to?(method)
def set_enable_starttls_auto(channel_data, smtp)
return unless smtp.respond_to?(:enable_starttls_auto)
context = enable_openssl_mode(openssl_verify_mode) if openssl_verify_mode
context ? smtp.send(method, context) : smtp.send(method)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_starttls_auto(context)
else
smtp.enable_starttls_auto
end
end
def set_enable_tls(channel_data, smtp)
return unless smtp.respond_to?(:enable_tls)
if channel_data[:smtp_openssl_verify_mode]
context = enable_openssl_mode(channel_data[:smtp_openssl_verify_mode])
smtp.enable_tls(context)
else
smtp.enable_tls
end
end
def enable_openssl_mode(smtp_openssl_verify_mode)
+6
View File
@@ -164,4 +164,10 @@ export default {
.v-popper--theme-tooltip .v-popper__arrow-container {
display: none;
}
.multiselect__input {
margin-bottom: 0px !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
-6
View File
@@ -42,12 +42,6 @@ class Inboxes extends CacheEnabledApiClient {
getCSATTemplateStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/csat_template`);
}
analyzeCSATTemplateUtility(inboxId, template) {
return axios.post(`${this.url}/${inboxId}/csat_template/analyze`, {
template,
});
}
}
export default new Inboxes();
@@ -66,7 +66,7 @@ textarea {
// Field base styles (Input, TextArea, Select)
@layer components {
.field-base {
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-sm font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
@apply block box-border w-full transition-colors duration-[0.25s] ease-[ease-in-out] focus:outline-n-brand dark:focus:outline-n-brand appearance-none mx-0 mt-0 mb-4 py-2 px-3 rounded-lg text-base font-normal bg-n-alpha-black2 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 border-none outline outline-1 outline-n-weak dark:outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6;
}
.field-disabled {
@@ -78,7 +78,7 @@ textarea {
}
}
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.no-margin)";
$form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not([type='radio']):not([type='range']):not([type='button']):not([type='submit']):not([type='reset']):not([type='color']):not([type='image']):not([type='hidden']):not(.reset-base):not(.multiselect__input):not(.no-margin)";
#{$form-input-selector} {
@apply field-base h-10;
@@ -92,7 +92,7 @@ $form-input-selector: "input[type]:not([type='file']):not([type='checkbox']):not
}
}
input[type='file'] {
input[type='file']:not(.multiselect__input) {
@apply leading-[1.15] mb-4 border-0 bg-transparent text-sm;
}
@@ -126,6 +126,13 @@ label:has(.help-text) {
}
}
// Error handling
.has-multi-select-error {
div.multiselect {
@apply mb-1;
}
}
// FormKit support
.formkit-outer[data-invalid='true'] {
#{$form-input-selector},
@@ -143,7 +150,9 @@ label:has(.help-text) {
#{$form-input-selector},
input:not([type]),
textarea,
select {
select,
.multiselect > .multiselect__tags,
.multiselect:not(.no-margin) {
@apply field-error;
}
@@ -13,6 +13,7 @@
@import 'base';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
@@ -65,84 +66,4 @@ body {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/**
* ============================================================================
* TYPOGRAPHY UTILITIES
* ============================================================================
*
* | Class | Use Case |
* |--------------------|----------------------------------------------------|
* | .text-body-main | <p>, <span>, general body text |
* | .text-body-para | <p> for paragraphs, larger text blocks |
* | .text-heading-1 | <h1>, page titles, panel headers |
* | .text-heading-2 | <h2>, section headings, card titles |
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
* | .text-label | <label>, form labels, field names |
* | .text-label-small | <small>, footnotes, tags, badges, captions |
* | .text-button | <button>, standard button text |
* | .text-button-small | <button>, small/compact button text |
*/
/* body-text-main: Main text style for general body text */
.text-body-main {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* body-text-paragraph: For paragraphs or larger blocks of text */
.text-body-para {
@apply font-inter text-sm font-420;
line-height: 21px; /* 150% */
letter-spacing: -0.21px;
}
/* heading-1: Large heading for pages and panels */
.text-heading-1 {
@apply font-inter text-lg font-520;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-2: Secondary heading for sections */
.text-heading-2 {
@apply font-inter text-base font-medium;
line-height: 24px; /* 133.333% */
letter-spacing: -0.27px;
}
/* heading-3: For card headings, breadcrumbs, subsections */
.text-heading-3 {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
letter-spacing: -0.27px;
}
/* label: Standard label text for form fields */
.text-label {
@apply font-inter text-sm font-medium;
line-height: 21px; /* 150% */
}
/* label-small: Smallest font for labels, footnotes, tags */
.text-label-small {
@apply font-inter text-xs font-440;
line-height: 16px; /* 133.333% */
letter-spacing: -0.24px;
}
/* button-text: Text for standard size buttons */
.text-button {
@apply font-inter text-sm font-460;
line-height: 21px; /* 150% */
letter-spacing: -0.28px;
}
/* button-text-small: Text for smaller buttons */
.text-button-small {
@apply font-inter text-xs font-440;
line-height: 18px; /* 150% */
letter-spacing: -0.24px;
}
}
@@ -0,0 +1,273 @@
@mixin label-multiselect-hover {
&::after {
@apply text-n-brand;
}
&:hover {
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
}
}
}
.multiselect {
&:not(.no-margin) {
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
.multiselect__select {
@apply cursor-not-allowed bg-transparent rounded-lg;
}
}
.multiselect--active {
> .multiselect__tags {
@apply outline-n-blue-border;
}
}
.multiselect__select {
@apply min-h-[2.875rem] p-0 right-0 top-0;
&::before {
@apply right-0;
}
}
.multiselect__content-wrapper {
@apply bg-n-alpha-black2 text-n-slate-12 backdrop-blur-[100px] border-0 border-none outline outline-1 outline-n-weak rounded-b-lg;
}
.multiselect__content {
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
}
p {
@apply mb-0;
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@apply bg-n-alpha-black2 text-n-slate-12;
}
&.multiselect__option--highlight:hover {
@apply bg-n-brand/10 text-n-slate-12;
&::after {
@apply bg-transparent text-center text-n-slate-12;
}
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@apply bg-n-brand/20 text-n-slate-12;
&::after {
@apply bg-transparent;
}
&.multiselect__option--highlight:hover {
@apply bg-transparent;
&::after:hover {
@apply text-n-slate-12 bg-transparent;
}
}
}
}
}
.multiselect__tags {
@apply bg-n-alpha-black2 border-0 grid items-center w-full border-none outline-1 outline outline-n-weak hover:outline-n-slate-6 m-0 min-h-[2.875rem] rounded-lg pt-0;
input {
@apply border-0 border-none bg-transparent dark:bg-transparent text-n-slate-12 placeholder:text-n-slate-10;
}
}
.multiselect__spinner {
background-color: transparent;
}
.multiselect__tags-wrap {
@apply inline-block leading-none mt-1;
}
.multiselect__placeholder {
@apply text-n-slate-10 font-normal pt-3;
}
.multiselect__tag {
@apply bg-n-alpha-white mt-1 text-n-slate-12 pr-6 pl-2.5 py-1.5;
}
.multiselect__tag-icon {
@include label-multiselect-hover;
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
}
.multiselect__single {
@apply bg-transparent text-n-slate-12 inline-block mb-0 py-3 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis;
}
}
.sidebar-labels-wrap {
&.has-edited,
&:hover {
.multiselect {
@apply cursor-pointer;
}
}
.multiselect {
> .multiselect__select {
@apply invisible;
}
> .multiselect__tags {
@apply outline-transparent;
}
&.multiselect--active > .multiselect__tags {
@apply outline-n-blue-border;
}
}
}
.multiselect-wrap--small {
// To be removed one SLA reports date picker is created
&.tiny {
.multiselect.no-margin {
@apply min-h-[32px];
}
.multiselect__select {
@apply min-h-[32px] h-8;
&::before {
@apply top-[60%];
}
}
.multiselect__tags {
@apply min-h-[32px] max-h-[32px];
.multiselect__single {
@apply pt-1 pb-1;
}
}
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply text-n-slate-12 rounded-lg text-sm min-h-[2.5rem];
}
.multiselect__input {
@apply h-[2.375rem] min-h-[2.375rem];
}
.multiselect__single {
@apply items-center flex m-0 text-sm max-h-[2.375rem] bg-transparent text-n-slate-12 py-3 px-0.5;
}
.multiselect__placeholder {
@apply m-0 py-2 px-0.5;
}
.multiselect__tag {
@apply py-[6px] my-[1px];
}
.multiselect__select {
@apply min-h-[2.5rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
}
.multiselect--disabled {
background-color: rgba(var(--black-alpha-2)) !important;
.multiselect__tags {
@apply hover:outline-n-weak;
}
}
.multiselect--active {
.multiselect__select::before {
@apply top-[62%];
}
}
.multiselect__select::before {
top: 60% !important;
}
.multiselect-wrap--medium {
.multiselect__tags,
.multiselect__input {
@apply items-center flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
@apply bg-n-alpha-black2 text-n-slate-12 text-sm h-12 min-h-[3rem];
}
.multiselect__input {
@apply h-[2.875rem] min-h-[2.875rem];
margin-bottom: 0 !important;
}
.multiselect__single {
@apply items-center flex m-0 text-sm py-1 px-0.5 bg-transparent text-n-slate-12;
}
.multiselect__placeholder {
@apply m-0 py-1 px-0.5;
}
.multiselect__select {
@apply min-h-[3rem];
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
@apply bg-transparent;
}
.multiselect__tags-wrap {
@apply flex-shrink-0;
}
}
@@ -49,7 +49,7 @@ const handleFetchUsers = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
@@ -78,7 +78,7 @@ const handleFetchUsers = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
</div>
@@ -20,7 +20,7 @@ const handleClick = () => {
<CardLayout class="[&>div]:px-5 cursor-pointer" @click="handleClick">
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between w-full items-center">
<h3 class="text-n-slate-12 text-heading-2">{{ title }}</h3>
<h3 class="text-n-slate-12 text-base font-medium">{{ title }}</h3>
<Button
xs
slate
@@ -29,14 +29,14 @@ const handleClick = () => {
@click.stop="handleClick"
/>
</div>
<p class="text-n-slate-11 text-body-para mb-0">{{ description }}</p>
<p class="text-n-slate-11 text-sm mb-0">{{ description }}</p>
</div>
<ul class="flex flex-col items-start gap-3 mt-3">
<li
v-for="feature in features"
:key="feature.id"
class="flex items-center gap-3 text-body-para"
class="flex items-center gap-3 text-sm"
>
<Icon
:icon="feature.icon"
@@ -39,6 +39,7 @@ const policyA = withCount({
description: 'Distributes conversations evenly among available agents',
assignmentOrder: 'round_robin',
conversationPriority: 'high',
enabled: true,
inboxes: [mockInboxes[0], mockInboxes[1]],
isFetchingInboxes: false,
});
@@ -49,6 +50,7 @@ const policyB = withCount({
description: 'Assigns based on capacity and workload',
assignmentOrder: 'capacity_based',
conversationPriority: 'medium',
enabled: true,
inboxes: [mockInboxes[2], mockInboxes[3]],
isFetchingInboxes: false,
});
@@ -59,6 +61,7 @@ const emptyPolicy = withCount({
description: 'Policy with no assigned inboxes',
assignmentOrder: 'manual',
conversationPriority: 'low',
enabled: false,
inboxes: [],
isFetchingInboxes: false,
});
@@ -15,6 +15,7 @@ const props = defineProps({
assignmentOrder: { type: String, default: '' },
conversationPriority: { type: String, default: '' },
assignedInboxCount: { type: Number, default: 0 },
enabled: { type: Boolean, default: false },
inboxes: { type: Array, default: () => [] },
isFetchingInboxes: { type: Boolean, default: false },
});
@@ -60,19 +61,39 @@ const handleFetchInboxes = () => {
<div class="flex flex-col gap-2 relative justify-between w-full">
<div class="flex items-center gap-3 justify-between w-full">
<div class="flex items-center gap-3">
<h3 class="text-heading-2 text-n-slate-12 line-clamp-1">
<h3 class="text-base font-medium text-n-slate-12 line-clamp-1">
{{ name }}
</h3>
<CardPopover
:title="
t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER')
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
<div class="flex items-center gap-2">
<div class="flex items-center rounded-md bg-n-alpha-2 h-6 px-2">
<span
class="text-xs"
:class="enabled ? 'text-n-teal-11' : 'text-n-slate-12'"
>
{{
enabled
? t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ACTIVE'
)
: t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.INACTIVE'
)
}}
</span>
</div>
<CardPopover
:title="
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.POPOVER'
)
"
icon="i-lucide-inbox"
:count="assignedInboxCount"
:items="inboxes"
:is-fetching="isFetchingInboxes"
@fetch="handleFetchInboxes"
/>
</div>
</div>
<div class="flex items-center gap-2">
<Button
@@ -89,18 +110,18 @@ const handleFetchInboxes = () => {
<Button icon="i-lucide-trash" sm slate ghost @click="handleDelete" />
</div>
</div>
<p class="text-n-slate-11 text-body-para line-clamp-1 mb-0 py-1">
<p class="text-n-slate-11 text-sm line-clamp-1 mb-0 py-1">
{{ description }}
</p>
<div class="flex items-center gap-3 py-1.5">
<span v-if="order" class="text-n-slate-11 text-body-para">
<span v-if="order" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.ORDER')}:`
}}
<span class="text-n-slate-12">{{ order }}</span>
</span>
<div v-if="order" class="w-px h-3 bg-n-strong" />
<span v-if="priority" class="text-n-slate-11 text-body-para">
<span v-if="priority" class="text-n-slate-11 text-sm">
{{
`${t('ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.INDEX.CARD.PRIORITY')}:`
}}
@@ -19,15 +19,11 @@ defineProps({
},
});
const emit = defineEmits(['delete', 'navigate']);
const emit = defineEmits(['delete']);
const handleDelete = itemId => {
emit('delete', itemId);
};
const handleNavigate = item => {
emit('navigate', item);
};
</script>
<template>
@@ -51,11 +47,7 @@ const handleNavigate = item => {
:key="item.id"
class="grid grid-cols-4 items-center gap-3 min-w-0 w-full justify-between h-[3.25rem] ltr:pr-2 rtl:pl-2"
>
<button
type="button"
class="flex items-center gap-2 col-span-2 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 rounded-lg py-1 px-1.5 -ml-1.5 transition-colors cursor-pointer group"
@click="handleNavigate(item)"
>
<div class="flex items-center gap-2 col-span-2">
<Icon
v-if="item.icon"
:icon="item.icon"
@@ -69,16 +61,10 @@ const handleNavigate = item => {
:size="20"
rounded-full
/>
<span
class="text-sm text-n-slate-12 truncate min-w-0 group-hover:text-n-blue-11 dark:group-hover:text-n-blue-10 transition-colors"
>
<span class="text-sm text-n-slate-12 truncate min-w-0">
{{ item.name }}
</span>
<Icon
icon="i-lucide-external-link"
class="size-3.5 text-n-slate-10 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</button>
</div>
<div class="flex items-start gap-2 col-span-1">
<span
@@ -2,7 +2,7 @@
import { computed, ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
@@ -15,9 +15,6 @@ const fairDistributionLimit = defineModel('fairDistributionLimit', {
},
});
// The model value is in seconds (for the backend/DB)
// DurationInput works in minutes internally
// We need to convert between seconds and minutes
const fairDistributionWindow = defineModel('fairDistributionWindow', {
type: Number,
default: 3600,
@@ -28,17 +25,6 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
const windowUnit = ref(DURATION_UNITS.MINUTES);
// Convert seconds to minutes for DurationInput
const windowInMinutes = computed({
get() {
return Math.floor((fairDistributionWindow.value || 0) / 60);
},
set(minutes) {
fairDistributionWindow.value = minutes * 60;
},
});
// Detect unit based on minutes (converted from seconds)
const detectUnit = minutes => {
const m = Number(minutes) || 0;
if (m === 0) return DURATION_UNITS.MINUTES;
@@ -48,7 +34,7 @@ const detectUnit = minutes => {
};
onMounted(() => {
windowUnit.value = detectUnit(windowInMinutes.value);
windowUnit.value = detectUnit(fairDistributionWindow.value);
});
</script>
@@ -87,9 +73,9 @@ onMounted(() => {
<div
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
>
<!-- allow 10 mins to 999 days (in minutes) -->
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model:model-value="windowInMinutes"
v-model:model-value="fairDistributionWindow"
v-model:unit="windowUnit"
:min="10"
:max="1438560"
@@ -1,6 +1,4 @@
<script setup>
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
id: {
type: String,
@@ -18,24 +16,12 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledLabel: {
type: String,
default: '',
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const handleChange = () => {
if (!props.isActive && !props.disabled) {
if (!props.isActive) {
emit('select', props.id);
}
};
@@ -43,37 +29,32 @@ const handleChange = () => {
<template>
<div
class="cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
class="relative cursor-pointer rounded-xl outline outline-1 p-4 transition-all duration-200 bg-n-solid-1 py-4 ltr:pl-4 rtl:pr-4 ltr:pr-6 rtl:pl-6"
:class="[
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
!disabled && !isActive ? 'hover:outline-n-strong' : '',
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
]"
@click="handleChange"
>
<div class="flex flex-col gap-2 items-start">
<div class="flex items-center justify-between w-full gap-3">
<div class="flex items-center gap-2">
<h3 class="text-heading-3 text-n-slate-12">
{{ label }}
</h3>
<Label v-if="disabled" :label="disabledLabel" color="amber" compact />
</div>
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
:disabled="disabled"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0 flex-shrink-0"
@change="handleChange"
/>
</div>
<p class="text-body-main text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
<div class="absolute top-4 right-4">
<input
:id="`${id}`"
:checked="isActive"
:value="id"
:name="id"
type="radio"
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
@change="handleChange"
/>
</div>
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ description }}
</p>
<slot />
</div>
</div>
</template>
@@ -6,6 +6,7 @@ const policyName = ref('Round Robin Policy');
const description = ref(
'Distributes conversations evenly among available agents'
);
const enabled = ref(true);
</script>
<template>
@@ -18,10 +19,13 @@ const description = ref(
<BaseInfo
v-model:policy-name="policyName"
v-model:description="description"
v-model:enabled="enabled"
name-label="Policy Name"
name-placeholder="Enter policy name"
description-label="Description"
description-placeholder="Enter policy description"
status-label="Status"
status-placeholder="Active"
/>
</div>
</Variant>
@@ -21,10 +21,10 @@ const handleButtonClick = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl mx-auto">
<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">
<span class="text-heading-1 text-n-slate-12">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
@@ -43,8 +43,8 @@ const handleButtonClick = () => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-4">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-4">
<slot name="default" />
</div>
</main>
@@ -19,7 +19,7 @@ const handleClick = () => {
<template>
<div
class="flex flex-col w-full outline-1 outline outline-n-container -outline-offset-1 group/cardLayout rounded-xl bg-n-solid-2"
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
>
<div
class="flex w-full gap-3 py-5"
@@ -15,11 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-heading-1 truncate text-n-slate-12">
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
</span>
<div class="flex items-center flex-row flex-shrink-0 gap-2">
@@ -32,18 +32,17 @@ const updateCurrentPage = page => {
@search="emit('search', $event)"
@update:sort="emit('update:sort', $event)"
/>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<slot name="default" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="25"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -3,8 +3,8 @@ import { computed, watch, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
const props = defineProps({
contactId: {
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TELEGRAM: 'i-ri-telegram-fill',
TIKTOK: 'i-ri-tiktok-fill',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
@@ -67,7 +66,6 @@ const defaultState = {
facebook: '',
github: '',
instagram: '',
telegram: '',
tiktok: '',
linkedin: '',
twitter: '',
@@ -105,13 +103,9 @@ const prepareStateBasedOnProps = () => {
countryCode = '',
country = '',
city = '',
socialTelegramUserName = '',
socialProfiles = {},
} = additionalAttributes || {};
const telegramUsername =
socialProfiles?.telegram || socialTelegramUserName || '';
Object.assign(state, {
id,
name,
@@ -125,10 +119,7 @@ const prepareStateBasedOnProps = () => {
countryCode,
country,
city,
socialProfiles: {
...socialProfiles,
telegram: telegramUsername,
},
socialProfiles,
},
});
};
@@ -32,9 +32,9 @@ const emit = defineEmits([
</script>
<template>
<header class="sticky top-0 z-10 px-6">
<header class="sticky top-0 z-10">
<div
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
>
<span class="text-xl font-medium truncate text-n-slate-12">
{{ headerTitle }}
@@ -68,7 +68,8 @@ const hasActiveSegments = computed(
);
const activeSegmentName = computed(() => props.activeSegment?.name);
const openCreateNewContactDialog = () => {
const openCreateNewContactDialog = async () => {
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
createNewContactDialogRef.value?.dialogRef.open();
};
const openContactImportDialog = () =>
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-5xl"
class="max-w-[60rem] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
/>
@@ -98,8 +98,8 @@ const showPagination = computed(() => {
@apply-filter="emit('applyFilter', $event)"
@clear-filters="emit('clearFilters')"
/>
<main class="flex-1 overflow-y-auto px-6">
<div class="w-full mx-auto max-w-5xl">
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@@ -114,12 +114,11 @@ const showPagination = computed(() => {
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0">
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalItems"
class="max-w-[67rem]"
:items-per-page="itemsPerPage"
@update:current-page="updateCurrentPage"
/>
@@ -1,58 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import Avatar from 'next/avatar/Avatar.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
contact: { type: Object, required: true },
selected: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
hideThumbnail: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation']);
const hovered = ref(false);
const onThumbnailHover = () => {
hovered.value = !props.hideThumbnail;
};
const onThumbnailLeave = () => {
hovered.value = false;
};
const selectedModel = computed({
get: () => props.selected,
set: value => {
emit('selectConversation', value);
},
});
</script>
<template>
<div
class="relative flex items-center flex-shrink-0"
@mouseenter="onThumbnailHover"
@mouseleave="onThumbnailLeave"
>
<Avatar
v-if="!hideThumbnail"
:name="contact.name"
:src="contact.thumbnail"
:size="24"
:status="contact.availability_status"
hide-offline-status
>
<template v-if="enableSelection" #overlay>
<div
v-if="hovered || selected"
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
@click.stop
>
<Checkbox v-model="selectedModel" />
</div>
</template>
</Avatar>
</div>
</template>
@@ -1,47 +0,0 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import VoiceCallStatus from './VoiceCallStatus.vue';
import UnreadBadge from './UnreadBadge.vue';
defineProps({
lastMessage: { type: Object, default: null },
voiceCallStatus: { type: String, default: '' },
voiceCallDirection: { type: String, default: '' },
unreadCount: { type: Number, default: 0 },
showExpandedPreview: { type: Boolean, default: false },
});
</script>
<template>
<div
class="grid grid-cols-[1fr_auto] gap-1.5"
:class="showExpandedPreview ? 'items-end' : 'items-center'"
>
<VoiceCallStatus
v-if="voiceCallStatus"
key="voice-status-row"
:status="voiceCallStatus"
:direction="voiceCallDirection"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<MessagePreview
v-else-if="lastMessage"
key="message-preview"
:message="lastMessage"
:multi-line="showExpandedPreview"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
/>
<span
v-else
key="no-messages"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
>
<Icon icon="i-lucide-info" class="size-3.5" />
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
</span>
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
</div>
</template>
@@ -1,175 +0,0 @@
<script setup>
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
import { useI18n } from 'vue-i18n';
import { useThrottleFn } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
labels: {
type: Array,
default: () => [],
},
disableToggle: {
type: Boolean,
default: false,
},
});
defineOptions({ inheritAttrs: false });
const attrs = useAttrs();
const slots = useSlots();
const { t } = useI18n();
const accountLabels = useMapGetter('labels/getLabels');
const activeLabels = computed(() => {
return accountLabels.value.filter(({ title }) =>
props.labels.includes(title)
);
});
const showAllLabels = ref(false);
const showExpandLabelButton = ref(false);
const labelPosition = ref(-1);
const labelContainer = ref(null);
// Show if there are labels OR if before slot exists
const showSection = computed(
() => activeLabels.value.length > 0 || !!slots.before
);
const computeVisibleLabelPosition = () => {
const container = labelContainer.value;
if (!container || activeLabels.value.length === 0) {
showExpandLabelButton.value = false;
return;
}
const labels = container.querySelectorAll('[data-label]');
if (labels.length === 0) {
showExpandLabelButton.value = false;
return;
}
// Early exit if all labels are visible
if (showAllLabels.value) return;
const beforeSlot = container.querySelector('[data-before-slot]');
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
let totalWidth = 0;
const labelsArray = Array.from(labels);
// Find last visible label index using some() - stops early on overflow
const overflowIndex = labelsArray.findIndex(label => {
totalWidth += label.offsetWidth + 6;
return totalWidth > availableWidth;
});
const visibleIndex =
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
labelPosition.value = visibleIndex;
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
};
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
const hiddenLabelsCount = computed(() => {
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
return activeLabels.value.length - labelPosition.value - 1;
});
// Check if all labels are hidden (none visible)
const allLabelsHidden = computed(() => {
return labelPosition.value === -1 && activeLabels.value.length > 0;
});
// Label text for button when disableToggle is true and all labels are hidden
const labelsCountText = computed(() => {
if (props.disableToggle && allLabelsHidden.value) {
return t('CONVERSATION.CARD.LABELS_COUNT', {
count: activeLabels.value.length,
});
}
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
return hiddenLabelsCount.value;
}
return '';
});
const hiddenLabelsTooltip = computed(() => {
if (!props.disableToggle) return '';
// When all labels are hidden, show all label titles
if (allLabelsHidden.value) {
return activeLabels.value.map(label => label.title).join(', ');
}
if (!showExpandLabelButton.value) return '';
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
return hiddenLabels.map(label => label.title).join(', ');
});
const tooltipText = computed(() => {
if (props.disableToggle && hiddenLabelsTooltip.value) {
return hiddenLabelsTooltip.value;
}
return showAllLabels.value
? t('CONVERSATION.CARD.HIDE_LABELS')
: t('CONVERSATION.CARD.SHOW_LABELS');
});
const onShowLabels = e => {
e.stopPropagation();
if (props.disableToggle) return;
showAllLabels.value = !showAllLabels.value;
nextTick(() => computeVisibleLabelPosition());
};
</script>
<template>
<div
v-if="showSection"
ref="labelContainer"
v-bind="attrs"
v-resize="throttledCalculate"
data-labels-container
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
>
<slot name="before" />
<Label
v-for="(label, index) in activeLabels"
:key="label ? label.id : index"
data-label
:label="label"
compact
:class="{
'invisible absolute': !showAllLabels && index > labelPosition,
}"
/>
<Button
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
v-tooltip.top="{
content: tooltipText,
delay: { show: 1000, hide: 0 },
}"
:label="labelsCountText"
xs
slate
:no-animation="disableToggle"
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
:class="{ 'cursor-default': disableToggle }"
@click="onShowLabels"
/>
</div>
<template v-else />
</template>
@@ -1,42 +1,207 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
defineProps({
priority: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
};
const iconName = computed(() => {
if (props.priority && icons[props.priority]) {
return icons[props.priority];
}
return props.showEmpty ? 'i-woot-priority-empty' : '';
});
</script>
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<Icon
v-tooltip.top="{
content: priority,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 text-n-slate-5"
/>
<div class="inline-flex items-center justify-center rounded-md">
<!-- Low Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.LOW"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-slate-6"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- Medium Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-slate-6"
/>
</g>
</svg>
<!-- High Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.HIGH"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-amber-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-amber-9"
/>
</g>
</svg>
<!-- Urgent Priority -->
<svg
v-if="priority === CONVERSATION_PRIORITY.URGENT"
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<mask
id="mask0_2030_12879"
style="mask-type: alpha"
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="20"
height="20"
>
<rect width="20" height="20" fill="#D9D9D9" />
</mask>
<g mask="url(#mask0_2030_12879)">
<rect
x="3.33301"
y="10"
width="3.33333"
height="6.66667"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="8.33301"
y="6.6665"
width="3.33333"
height="10"
rx="1.66667"
class="fill-n-ruby-9"
/>
<rect
x="13.333"
y="3.3335"
width="3.33333"
height="13.3333"
rx="1.66667"
class="fill-n-ruby-9"
/>
</g>
</svg>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: {
type: String,
default: '',
},
showEmpty: {
type: Boolean,
default: false,
},
});
const icons = {
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
};
const iconName = computed(() => {
if (props.status && icons[props.status]) {
return icons[props.status];
}
return props.showEmpty ? 'i-woot-status-empty' : '';
});
</script>
<template>
<Icon
v-tooltip.top="{
content: status,
delay: { show: 500, hide: 0 },
}"
:icon="iconName"
class="size-4 flex-shrink-0"
/>
</template>
@@ -1,160 +0,0 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
hideThumbnail: { type: Boolean, default: false },
enableSelection: { type: Boolean, default: true },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
const slaCardLabel = useTemplateRef('slaCardLabel');
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat?.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const showLabelsSection = computed(
() => props.chat.labels?.length > 0 || hasSlaPolicyId.value
);
const showMetaSection = computed(
() =>
(!props.isInboxView && props.showInboxName) ||
(props.showAssignee && props.assignee?.name) ||
props.chat.priority
);
const onSelectConversation = checked => {
emit('selectConversation', checked);
};
</script>
<template>
<div
class="relative flex items-start max-w-full cursor-pointer group transition-colors duration-150 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="{
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
'bg-n-slate-2': selected,
'px-0': compact,
'px-2 rounded-lg': !compact,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<div class="relative">
<CardAvatar
v-if="!compact && !hideThumbnail"
:contact="currentContact"
:selected="selected"
:enable-selection="enableSelection"
:hide-thumbnail="hideThumbnail"
:class="showMetaSection ? 'mt-8' : 'mt-4'"
@select-conversation="onSelectConversation"
/>
</div>
<div
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
>
<div
v-if="showMetaSection"
class="flex items-center min-w-0 gap-1"
:class="{
'ltr:ml-2 rtl:mr-2': !compact,
'mx-2': compact,
}"
>
<InboxName
v-if="showInboxName && !isInboxView"
:inbox="inbox"
class="flex-1 min-w-0"
/>
<div
v-if="showAssignee || chat.priority"
class="flex items-center gap-2 flex-shrink-0 h-4"
:class="{
'flex-1 justify-between': isInboxView || !showInboxName,
}"
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-label-small px-0 inline-flex items-center truncate gap-0.5 max-w-36"
>
<Icon icon="i-lucide-user-round" class="text-n-slate-11 size-3" />
{{ assignee.name }}
</span>
<CardPriorityIcon
v-if="chat.priority"
:priority="chat.priority"
class="flex-shrink-0"
/>
</div>
</div>
<div class="inline-flex items-center justify-between w-full">
<h4
class="text-heading-3 mx-2 capitalize pt-0.5 truncate flex-1 min-w-0 text-n-slate-12"
:class="hasUnread ? '!font-520' : ''"
>
{{ currentContact.name }}
</h4>
<span class="text-label-small text-n-slate-11">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
/>
</span>
</div>
<div class="ltr:ml-2 rtl:mr-2 leading-6 h-6 min-w-0">
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
/>
</div>
<CardLabels
v-if="showLabelsSection"
:labels="chat.labels"
class="mt-0.5 mx-2"
>
<template v-if="hasSlaPolicyId" #before>
<SLACardLabel ref="slaCardLabel" :chat="chat" data-before-slot />
</template>
</CardLabels>
</div>
</div>
</template>
@@ -1,182 +0,0 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
import CardPriorityIcon from './CardPriorityIcon.vue';
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
import Avatar from 'next/avatar/Avatar.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
import CardStatusIcon from './CardStatusIcon.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
chat: { type: Object, required: true },
currentContact: { type: Object, required: true },
assignee: { type: Object, default: () => ({}) },
inbox: { type: Object, default: () => ({}) },
selected: { type: Boolean, default: false },
isActiveChat: { type: Boolean, default: false },
showAssignee: { type: Boolean, default: false },
showInboxName: { type: Boolean, default: false },
isInboxView: { type: Boolean, default: false },
});
const emit = defineEmits([
'selectConversation',
'deSelectConversation',
'click',
'contextmenu',
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => ({
status: props.chat.additional_attributes?.call_status,
direction: props.chat.additional_attributes?.call_direction,
}));
const unreadCount = computed(() => props.chat.unread_count);
const slaCardLabel = useTemplateRef('slaCardLabel');
const hasSlaPolicyId = computed(
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
);
const selectedModel = computed({
get: () => props.selected,
set: value => {
if (value) {
emit('selectConversation', value);
} else {
emit('deSelectConversation', value);
}
},
});
</script>
<template>
<div
class="relative cursor-pointer group transition-colors duration-150 grid gap-4 items-center px-2 h-12"
:class="{
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
}"
@click="$emit('click', $event)"
@contextmenu="$emit('contextmenu', $event)"
>
<!-- LEFT SECTION -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
<Checkbox v-model="selectedModel" />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardPriorityIcon :priority="chat.priority" show-empty />
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<Avatar
v-if="showAssignee && assignee.name"
v-tooltip.top="{
content: assignee.name,
delay: { show: 500, hide: 0 },
}"
:name="assignee.name"
:src="assignee.thumbnail"
:size="14"
:status="assignee.availability_status"
hide-offline-status
rounded-full
/>
<Icon
v-else
icon="i-woot-empty-assignee"
class="size-4 text-n-slate-7"
/>
</div>
<div class="w-4 flex items-center justify-center flex-shrink-0">
<CardStatusIcon :status="chat.status" show-empty />
</div>
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
</div>
<div v-if="!isInboxView" class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
<div
v-tooltip.top="{
content: chat.id,
delay: { show: 500, hide: 0 },
}"
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
>
<Icon
icon="i-woot-hash"
class="size-3.5 text-n-slate-10 flex-shrink-0"
/>
<span class="text-label-small text-n-slate-11 truncate">
{{ chat.id }}
</span>
</div>
<CardAvatar
:contact="currentContact"
:selected="false"
:enable-selection="false"
:hide-thumbnail="false"
/>
<h4
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
>
{{ currentContact.name }}
</h4>
<CardContent
:last-message="lastMessageInChat"
:voice-call-status="voiceCallData.status"
:voice-call-direction="voiceCallData.direction"
:unread-count="unreadCount"
:show-expanded-preview="false"
/>
</div>
<!-- RIGHT SECTION -->
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
<div v-if="showLabelsSection" class="min-w-0 w-full">
<CardLabels
:labels="chat.labels"
disable-toggle
class="my-0 [&>div]:justify-end justify-end"
/>
</div>
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
<SLACardLabel ref="slaCardLabel" :chat="chat" />
</div>
<div class="flex-shrink-0 w-[4.375rem] text-end">
<TimeAgo
:last-activity-timestamp="chat.timestamp"
:created-at-timestamp="chat.created_at"
class="font-440 !text-xs text-n-slate-11"
/>
</div>
</div>
</div>
</template>
@@ -1,245 +0,0 @@
<script setup>
import { computed, ref, inject } from 'vue';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import ConversationCardExpanded from './ConversationCardExpanded.vue';
import ConversationCardCompact from './ConversationCardCompact.vue';
// import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
const props = defineProps({
activeLabel: { type: String, default: '' },
chat: { type: Object, default: () => ({}) },
hideThumbnail: { type: Boolean, default: false },
teamId: { type: [String, Number], default: 0 },
foldersId: { type: [String, Number], default: 0 },
showAssignee: { type: Boolean, default: false },
conversationType: { type: String, default: '' },
selected: { type: Boolean, default: false },
compact: { type: Boolean, default: false },
enableContextMenu: { type: Boolean, default: false },
allowedContextMenuOptions: { type: Array, default: () => [] },
enableSelection: { type: Boolean, default: true },
isExpandedLayout: { type: Boolean, default: false },
isPreviousConversations: { type: Boolean, default: false },
});
const emit = defineEmits([
'contextMenuToggle',
'assignAgent',
'assignLabel',
'removeLabel',
'assignTeam',
'markAsUnread',
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
'selectConversation',
'deSelectConversation',
]);
const router = useRouter();
const isLargeScreen = inject('isLargeScreen', ref(false));
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
const showExpanded = computed(() => {
return props.isExpandedLayout && isLargeScreen.value;
});
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const currentChat = useMapGetter('getSelectedChat');
const inboxesList = useMapGetter('inboxes/getInboxes');
const activeInbox = useMapGetter('getSelectedInbox');
const accountId = useMapGetter('getCurrentAccountId');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const chatMetadata = computed(() => props.chat.meta || {});
const assignee = computed(() => chatMetadata.value.assignee || {});
const senderId = computed(() => chatMetadata.value.sender?.id);
const currentContact = computed(() =>
senderId.value ? contactGetter.value(senderId.value) : {}
);
const hasActiveInbox = computed(() => activeInbox.value);
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
const inbox = computed(() => {
const inboxId = props.chat.inbox_id;
return inboxId ? inboxGetter.value(inboxId) : {};
});
const showInboxName = computed(() => inboxesList.value.length > 1);
const conversationPath = computed(() => {
return frontendURL(
conversationUrl({
accountId: accountId.value,
activeInbox: activeInbox.value,
id: props.chat.id,
label: props.activeLabel,
teamId: props.teamId,
conversationType: props.conversationType,
foldersId: props.foldersId,
})
);
});
const onCardClick = e => {
const path = conversationPath.value;
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
if (isActiveChat.value) return;
router.push({ path });
};
const onSelectConversation = checked => {
if (checked) {
emit('selectConversation', props.chat.id, inbox.value.id);
} else {
emit('deSelectConversation', props.chat.id, inbox.value.id);
}
};
const openContextMenu = e => {
if (!props.enableContextMenu) return;
e.preventDefault();
emit('contextMenuToggle', true);
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
emit('contextMenuToggle', false);
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
};
const onUpdateConversation = (status, snoozedUntil) => {
closeContextMenu();
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
};
const onAssignAgent = agent => {
emit('assignAgent', agent, [props.chat.id]);
closeContextMenu();
};
const onAssignLabel = label => {
emit('assignLabel', [label.title], [props.chat.id]);
};
const onRemoveLabel = label => {
emit('removeLabel', [label.title], [props.chat.id]);
};
const onAssignTeam = team => {
emit('assignTeam', team, props.chat.id);
closeContextMenu();
};
const markAsUnread = () => {
emit('markAsUnread', props.chat.id);
closeContextMenu();
};
const markAsRead = () => {
emit('markAsRead', props.chat.id);
closeContextMenu();
};
const assignPriority = priority => {
emit('assignPriority', priority, props.chat.id);
closeContextMenu();
};
const deleteConversation = () => {
emit('deleteConversation', props.chat.id);
closeContextMenu();
};
</script>
<template>
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
<ConversationCardExpanded
v-if="showExpanded"
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@de-select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) -->
<ConversationCardCompact
v-else
:chat="chat"
:current-contact="currentContact"
:assignee="assignee"
:inbox="inbox"
:selected="selected"
:is-active-chat="isActiveChat"
:compact="compact"
:show-assignee="showAssignee"
:show-inbox-name="showInboxName"
:hide-thumbnail="hideThumbnail"
:enable-selection="enableSelection"
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
@select-conversation="onSelectConversation"
@click="onCardClick"
@contextmenu="openContextMenu"
/>
<ContextMenu
v-if="showContextMenu"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="chat.unread_count > 0"
:conversation-labels="chat.labels"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@remove-label="onRemoveLabel"
@assign-team="onAssignTeam"
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</template>
@@ -1,156 +0,0 @@
<script setup>
import { computed } from 'vue';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
showMessageType: {
type: Boolean,
default: true,
},
defaultEmptyMessage: {
type: String,
default: '',
},
multiLine: {
type: Boolean,
default: false,
},
});
const { getPlainText } = useMessageFormatter();
const attachmentIcons = {
image: 'i-lucide-image',
audio: 'i-lucide-headphones',
video: 'i-lucide-video',
file: 'i-lucide-file',
location: 'i-lucide-map-pin',
fallback: 'i-lucide-link-2',
};
const messageByAgent = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const isMessageAnActivity = computed(() => {
const { message_type: messageType } = props.message;
return messageType === MESSAGE_TYPE.ACTIVITY;
});
const isMessagePrivate = computed(() => {
const { private: isPrivate } = props.message;
return isPrivate;
});
const parsedLastMessage = computed(() => {
const { content_attributes: contentAttributes } = props.message;
const { email: { subject } = {} } = contentAttributes || {};
return getPlainText(subject || props.message.content);
});
const lastMessageFileType = computed(() => {
const [{ file_type: fileType } = {}] = props.message.attachments;
return fileType;
});
const attachmentIcon = computed(() => {
return attachmentIcons[lastMessageFileType.value];
});
const attachmentMessageContent = computed(() => {
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
});
const isMessageSticker = computed(() => {
return props.message && props.message.content_type === 'sticker';
});
</script>
<template>
<div
class="min-w-0 text-sm"
:class="
multiLine
? 'flex items-start gap-1'
: 'grid grid-cols-[auto_1fr] items-center gap-1'
"
>
<template v-if="showMessageType && !multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="size-3.5"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="size-3.5"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="size-3.5"
/>
</template>
<span
class="min-w-0 text-body-main"
:class="multiLine ? 'line-clamp-2' : 'truncate'"
>
<!-- Case for previous and conversation conversation card -->
<template v-if="showMessageType && multiLine">
<Icon
v-if="isMessagePrivate"
icon="i-lucide-lock-keyhole"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="messageByAgent"
icon="i-lucide-undo-2"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<Icon
v-else-if="isMessageAnActivity"
icon="i-lucide-info"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
</template>
<span
v-if="message.content && isMessageSticker"
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
>
<Icon icon="i-lucide-image" class="size-3.5" />
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<template v-else-if="message.content">
{{ parsedLastMessage }}
</template>
<span
v-else-if="message.attachments"
class="inline-block align-middle truncate"
>
<Icon
v-if="attachmentIcon && showMessageType"
:icon="attachmentIcon"
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
/>
<span class="inline-block align-middle">
{{ $t(attachmentMessageContent) }}
</span>
</span>
<template v-else>
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
</template>
</span>
</div>
</template>
@@ -1,28 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
count: { type: Number, required: true },
alignBottom: { type: Boolean, default: false },
});
const { t } = useI18n();
const displayCount = computed(() =>
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
);
</script>
<template>
<span
v-if="count > 0"
class="bg-n-blue-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
:class="{
'mb-0.5': alignBottom,
}"
>
{{ displayCount }}
</span>
<span v-else />
</template>
@@ -1,69 +0,0 @@
<script setup>
import { computed } from 'vue';
import {
VOICE_CALL_STATUS,
VOICE_CALL_DIRECTION,
} from 'dashboard/components-next/message/constants';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
status: { type: String, default: '' },
direction: { type: String, default: '' },
});
const LABEL_KEYS = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
};
const COLOR_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
};
const isOutbound = computed(
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
);
const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
);
const labelKey = computed(() => {
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
if (props.status === VOICE_CALL_STATUS.RINGING) {
return isOutbound.value
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}
return isFailed.value
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
});
const iconName = computed(() => {
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
});
const statusColor = computed(
() => COLOR_MAP[props.status] || 'text-n-slate-11'
);
</script>
<template>
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
<span class="truncate text-body-main" :class="statusColor">
{{ $t(labelKey) }}
</span>
</div>
</template>
@@ -1,19 +0,0 @@
<script setup>
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
defineProps({
inbox: {
type: Object,
default: () => {},
},
});
</script>
<template>
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
<span class="truncate text-label-small text-n-slate-11">
{{ inbox.name }}
</span>
</div>
</template>
@@ -20,10 +20,10 @@ const showCopilotTab = computed(() =>
const { uiSettings } = useUISettings();
const isContactSidebarOpen = computed(
() => uiSettings.value.is_contact_sidebar_open || false
() => uiSettings.value.is_contact_sidebar_open
);
const isCopilotPanelOpen = computed(
() => uiSettings.value.is_copilot_panel_open || false
() => uiSettings.value.is_copilot_panel_open
);
const toggleConversationSidebarToggle = () => {
@@ -35,7 +35,7 @@ const toggleConversationSidebarToggle = () => {
const handleConversationSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: !isContactSidebarOpen.value,
is_contact_sidebar_open: true,
is_copilot_panel_open: false,
});
};
@@ -43,7 +43,7 @@ const handleConversationSidebarToggle = () => {
const handleCopilotSidebarToggle = () => {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: !isCopilotPanelOpen.value,
is_copilot_panel_open: true,
});
};
@@ -1,100 +0,0 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { evaluateSLAStatus } from '@chatwoot/utils';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import SLAPopoverCard from './SLAPopoverCard.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
chat: {
type: Object,
default: () => ({}),
},
showExtendedInfo: {
type: Boolean,
default: false,
},
});
const REFRESH_INTERVAL = 60000;
const timer = ref(null);
const slaStatus = ref({
threshold: null,
isSlaMissed: false,
type: null,
icon: null,
});
defineOptions({
inheritAttrs: false,
});
const appliedSLA = computed(() => props.chat?.applied_sla);
const slaEvents = computed(() => props.chat?.sla_events);
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
const conversation = computed(() => useCamelCase(props.chat, { deep: true }));
const showSlaPopoverCard = computed(
() => props.showExtendedInfo && slaEvents.value?.length > 0
);
const updateSlaStatus = () => {
slaStatus.value = evaluateSLAStatus({
appliedSla: appliedSLA.value || {},
chat: conversation.value,
});
};
const createTimer = () => {
timer.value = setTimeout(() => {
updateSlaStatus();
createTimer();
}, REFRESH_INTERVAL);
};
onMounted(() => {
updateSlaStatus();
createTimer();
});
onUnmounted(() => {
if (timer.value) {
clearTimeout(timer.value);
}
});
watch(() => props.chat, updateSlaStatus);
defineExpose({
hasSlaThreshold,
});
</script>
<template>
<div
v-if="hasSlaThreshold"
v-bind="$attrs"
class="relative flex items-center cursor-pointer min-w-fit group"
>
<Label
:label="slaStatus.threshold"
:color="isSlaMissed ? 'ruby' : 'amber'"
compact
>
<template #icon>
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
</template>
</Label>
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="start-0 md:start-auto md:end-0 top-7 hidden group-hover:flex"
/>
</div>
<template v-else />
</template>
@@ -1,36 +0,0 @@
<script setup>
import { format, fromUnixTime } from 'date-fns';
defineProps({
label: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
});
const formatDate = timestamp =>
format(fromUnixTime(timestamp), 'MMM dd, yyyy, hh:mm a');
</script>
<template>
<div class="flex justify-between w-full">
<span
class="text-sm sticky top-0 h-fit font-normal tracking-[-0.6%] min-w-[140px] truncate text-n-slate-11"
>
{{ label }}
</span>
<div class="flex flex-col w-full gap-2">
<span
v-for="item in items"
:key="item.id"
class="text-sm font-normal text-n-slate-12 text-right tabular-nums"
>
{{ formatDate(item.created_at) }}
</span>
<slot name="showMore" />
</div>
</div>
</template>
@@ -1,86 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import wootConstants from 'dashboard/constants/globals';
import SLAEventItem from './SLAEventItem.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
slaMissedEvents: {
type: Array,
required: true,
},
});
const { SLA_MISS_TYPES } = wootConstants;
const shouldShowAllNrts = ref(false);
const frtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.FRT
)
);
const nrtMisses = computed(() => {
const missedEvents = props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.NRT
);
return shouldShowAllNrts.value ? missedEvents : missedEvents.slice(0, 6);
});
const rtMisses = computed(() =>
props.slaMissedEvents.filter(
slaEvent => slaEvent.event_type === SLA_MISS_TYPES.RT
)
);
const shouldShowMoreNRTButton = computed(() => nrtMisses.value.length > 6);
const toggleShowAllNRT = () => {
shouldShowAllNrts.value = !shouldShowAllNrts.value;
};
</script>
<template>
<div
class="absolute flex flex-col items-start border-n-strong bg-n-solid-3 w-96 backdrop-blur-[100px] px-6 py-5 z-50 shadow rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-n-slate-12">
{{ $t('SLA.EVENTS.TITLE') }}
</span>
<SLAEventItem
v-if="frtMisses.length"
:label="$t('SLA.EVENTS.FRT')"
:items="frtMisses"
/>
<SLAEventItem
v-if="nrtMisses.length"
:label="$t('SLA.EVENTS.NRT')"
:items="nrtMisses"
>
<template #showMore>
<div
v-if="shouldShowMoreNRTButton"
class="flex flex-col items-end w-full"
>
<Button
link
xs
slate
class="hover:!no-underline"
:icon="!shouldShowAllNrts ? 'i-lucide-plus' : ''"
:label="
shouldShowAllNrts
? $t('SLA.EVENTS.HIDE', { count: nrtMisses.length })
: $t('SLA.EVENTS.SHOW_MORE', { count: nrtMisses.length })
"
@click="toggleShowAllNRT"
/>
</div>
</template>
</SLAEventItem>
<SLAEventItem
v-if="rtMisses.length"
:label="$t('SLA.EVENTS.RT')"
:items="rtMisses"
/>
</div>
</template>
@@ -1,10 +1,8 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
import AttributeBadge from 'dashboard/components-next/CustomAttributes/AttributeBadge.vue';
import { computed } from 'vue';
const props = defineProps({
attribute: {
@@ -20,7 +18,7 @@ const props = defineProps({
const emit = defineEmits(['edit', 'delete']);
const iconByType = {
text: 'i-lucide-menu',
text: 'i-lucide-align-justify',
checkbox: 'i-lucide-circle-check-big',
list: 'i-lucide-list',
date: 'i-lucide-calendar',
@@ -30,66 +28,61 @@ const iconByType = {
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-menu';
return iconByType[typeKey] || 'i-lucide-align-justify';
});
</script>
<template>
<div class="flex flex-col py-4 min-w-0">
<div class="flex justify-between flex-row items-center gap-4 min-w-0">
<div class="flex items-center gap-4 min-w-0">
<div
class="flex items-center flex-shrink-0 size-10 justify-center rounded-xl outline outline-1 outline-n-weak -outline-offset-1"
>
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
</div>
<div class="flex flex-col gap-1.5 items-start min-w-0 overflow-hidden">
<div class="flex items-center gap-2 min-w-0">
<h4 class="text-heading-3 truncate text-n-slate-12 min-w-0">
{{ attribute.label }}
</h4>
<div class="flex items-center gap-1.5">
<Label :label="attribute.type" compact />
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
</div>
<div
class="flex flex-col gap-2 p-4 bg-n-solid-1 rounded-2xl outline outline-1 outline-n-container"
>
<div class="flex flex-wrap gap-2 justify-between items-center">
<div class="flex flex-wrap gap-2 items-center min-w-0">
<h4 class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.label }}
</h4>
<div class="w-px h-3 bg-n-strong" />
<div class="flex gap-2 items-center text-sm text-n-slate-11">
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon :icon="attributeIcon" class="size-4" />
<span class="text-sm">{{ attribute.type }}</span>
</div>
<div class="grid grid-cols-[auto_1fr] items-center gap-1.5">
<Icon icon="i-lucide-key-round" class="size-3.5 text-n-slate-11" />
<div class="flex items-center gap-2 min-w-0">
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.value }}
</span>
<template
v-if="attribute.attribute_description || attribute.description"
>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ attribute.attribute_description || attribute.description }}
</span>
</template>
</div>
<div class="w-px h-3 bg-n-weak" />
<div class="flex items-center gap-1.5 text-n-slate-11">
<Icon icon="i-lucide-key-round" class="size-4" />
<span class="line-clamp-1 text-sm">{{ attribute.value }}</span>
</div>
</div>
</div>
<div class="flex gap-3 justify-end flex-shrink-0">
<Button
icon="i-woot-edit-pen"
slate
sm
@click="emit('edit', attribute)"
<div class="flex gap-2 items-center">
<AttributeBadge
v-for="badge in badges"
:key="badge.type"
:type="badge.type"
/>
<div
v-if="badges.length > 0"
class="w-px h-3 bg-n-strong ltr:ml-1.5 rtl:mr-1.5"
/>
<Button
icon="i-woot-bin"
slate
sm
class="hover:enabled:text-n-ruby-11 hover:enabled:bg-n-ruby-2"
icon="i-lucide-pencil-line"
size="sm"
color="slate"
ghost
@click="emit('edit', attribute)"
/>
<div class="w-px h-3 bg-n-strong" />
<Button
icon="i-lucide-trash"
size="sm"
color="slate"
ghost
@click="emit('delete', attribute)"
/>
</div>
</div>
<p class="mb-0 text-sm text-n-slate-11">
{{ attribute.attribute_description || attribute.description || '' }}
</p>
</div>
</template>
@@ -35,20 +35,18 @@ const handleDelete = () => {
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<h5 class="text-heading-3 text-n-slate-12 line-clamp-1">
<h5 class="text-sm font-medium text-n-slate-12 line-clamp-1">
{{ attribute.label }}
</h5>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon :icon="attributeIcon" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{ attribute.type }}</span>
<span class="text-sm text-n-slate-11">{{ attribute.type }}</span>
</div>
<div class="w-px h-2.5 bg-n-slate-5" />
<div class="flex gap-1.5 items-center">
<Icon icon="i-lucide-key-round" class="size-4 text-n-slate-11" />
<span class="text-body-para text-n-slate-11">{{
attribute.value
}}</span>
<span class="text-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
@@ -129,10 +129,10 @@ const handleDelete = attribute => {
<div class="flex flex-col gap-2 items-start px-5 py-4">
<div class="flex justify-between items-center w-full">
<div class="flex flex-col gap-2">
<h3 class="text-heading-2 text-n-slate-12">
<h3 class="text-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-body-para text-n-slate-11">
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
@@ -2,7 +2,6 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
const props = defineProps({
type: {
@@ -19,13 +18,11 @@ const attributeConfig = {
colorClass: 'text-n-blue-11',
icon: 'i-lucide-message-circle',
labelKey: 'ATTRIBUTES_MGMT.BADGES.PRE_CHAT',
color: 'slate',
},
resolution: {
colorClass: 'text-n-teal-11',
icon: 'i-lucide-circle-check-big',
labelKey: 'ATTRIBUTES_MGMT.BADGES.RESOLUTION',
color: 'slate',
},
};
const config = computed(
@@ -34,9 +31,12 @@ const config = computed(
</script>
<template>
<Label :label="t(config.labelKey)" :color="config.color" compact>
<template #icon>
<Icon :icon="config.icon" class="size-3.5 text-n-slate-12" />
</template>
</Label>
<div
class="flex gap-1 justify-center items-center px-1.5 py-1 rounded-md shadow outline-1 outline outline-n-container bg-n-solid-2"
>
<Icon :icon="config.icon" class="size-4" :class="config.colorClass" />
<span class="text-xs" :class="config.colorClass">{{
t(config.labelKey)
}}</span>
</div>
</template>
@@ -26,7 +26,7 @@ defineProps({
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-5xl mx-auto overflow-hidden h-full max-h-[28rem]"
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
>
<div
v-if="showBackdrop"
@@ -59,8 +59,8 @@ const togglePortalSwitcher = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6 pb-3">
<div class="w-full max-w-5xl mx-auto">
<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
v-if="showHeaderTitle"
class="flex items-center justify-start h-20 gap-2"
@@ -95,17 +95,16 @@ const togglePortalSwitcher = () => {
<slot name="header-actions" />
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl mx-auto py-3">
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[60rem] mx-auto py-3 lg:px-6">
<slot name="content" />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalItems"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="updateCurrentPage"
/>
</footer>
@@ -62,7 +62,6 @@ const onCreate = async () => {
from: route.name,
});
selectedLocale.value = '';
dialogRef.value?.close();
useAlert(
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
@@ -1,5 +1,5 @@
<script setup>
import { reactive, ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
@@ -59,23 +59,6 @@ const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const formState = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const clearFormState = () => {
Object.assign(formState, {
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
};
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
@@ -157,14 +140,12 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
if (!inbox) clearFormState();
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
clearFormState();
};
const closeCompose = () => {
@@ -179,12 +160,6 @@ const closeCompose = () => {
emit('close');
};
const discardCompose = () => {
clearFormState();
formState.message = '';
closeCompose();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
@@ -196,7 +171,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
discardCompose();
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
@@ -218,11 +193,7 @@ watch(
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) {
clearSelectedContact();
clearFormState();
formState.message = '';
}
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(
@@ -294,7 +265,6 @@ useKeyboardEvents(keyboardEvents);
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:form-state="formState"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
@@ -315,7 +285,7 @@ useKeyboardEvents(keyboardEvents);
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="discardCompose"
@discard="closeCompose"
/>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -37,7 +37,6 @@ const props = defineProps({
contactsUiFlags: { type: Object, default: null },
messageSignature: { type: String, default: '' },
sendWithSignature: { type: Boolean, default: false },
formState: { type: Object, required: true },
});
const emit = defineEmits([
@@ -58,13 +57,13 @@ const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = props.formState || {
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
};
});
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
@@ -95,7 +95,6 @@ const inputClass = computed(() => {
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@@ -128,7 +127,6 @@ const inputClass = computed(() => {
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
allow-create
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@@ -70,7 +70,7 @@ describe('composeConversationHelper', () => {
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-woot-mail',
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
@@ -1,31 +0,0 @@
<script setup>
import { useToggle } from '@vueuse/core';
const props = defineProps({
title: {
type: String,
required: true,
},
defaultOpen: {
type: Boolean,
default: true,
},
});
const [isOpen, toggle] = useToggle(props.defaultOpen);
</script>
<template>
<div
v-bind="$attrs"
class="flex items-center justify-between w-full cursor-pointer pb-2 pt-4 border-t border-n-weak"
@click="toggle()"
>
<span class="text-heading-2 text-n-slate-12 w-full">
{{ title }}
</span>
</div>
<div v-if="isOpen" class="w-full space-y-4 pt-4 mb-4">
<slot />
</div>
</template>
@@ -1,37 +0,0 @@
<script setup>
defineProps({
label: {
type: String,
required: true,
},
helpText: {
type: String,
default: '',
},
});
</script>
<template>
<div class="w-full py-2 mb-2 [interpolate-size:allow-keywords]">
<div
class="grid grid-cols-1 lg:grid-cols-8 gap-1.5 lg:gap-4 items-start lg:items-center"
>
<label class="text-heading-3 text-n-slate-12 col-span-1 lg:col-span-2">
{{ label }}
</label>
<div class="col-span-1 lg:col-span-6">
<slot />
</div>
</div>
<div v-if="helpText" class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
v-if="helpText"
class="mt-1.5 col-span-1 lg:col-span-6 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ helpText }}
</p>
</div>
<slot name="extra" />
</div>
</template>
@@ -1,45 +0,0 @@
<script setup>
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
defineProps({
header: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
compact: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
</script>
<template>
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}
</span>
</div>
<div
v-if="$slots.editor"
class="w-full border-t border-n-weak"
:class="{ 'p-0': compact, 'px-4 pb-4 pt-2': !compact }"
>
<slot name="editor" />
</div>
</div>
</template>
@@ -112,19 +112,6 @@ const containerStyles = computed(() => ({
height: `${props.size}px`,
}));
const borderRadiusClass = computed(() => {
if (props.roundedFull) {
return 'rounded-full';
}
// Approximates 25% of size
if (props.size <= 16) return 'rounded'; // 4px
if (props.size <= 24) return 'rounded-md'; // 6px
if (props.size <= 32) return 'rounded-lg'; // 8px
if (props.size <= 48) return 'rounded-xl'; // 12px
return 'rounded-2xl'; // 16px
});
const avatarStyles = computed(() => ({
...containerStyles.value,
backgroundColor:
@@ -197,7 +184,7 @@ watch(
<template>
<span
class="relative inline-flex group/avatar z-0 flex-shrink-0 align-middle"
class="relative inline-flex group/avatar z-0 flex-shrink-0"
:style="containerStyles"
>
<!-- Status Badge -->
@@ -229,9 +216,9 @@ watch(
<!-- Avatar Container -->
<span
role="img"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium outline outline-1 -outline-offset-1 outline-[rgb(0_0_0_/_0.03)] dark:outline-[rgb(255_255_255_/_0.04)]"
class="relative inline-flex items-center justify-center object-cover overflow-hidden font-medium"
:class="[
borderRadiusClass,
roundedFull ? 'rounded-full' : 'rounded-xl',
{
'dark:!bg-[var(--dark-bg)] dark:!text-[var(--dark-text)]':
!showDefaultAvatar && (!src || !isImageValid),
@@ -280,8 +267,7 @@ watch(
:handle-image-upload="handleImageUpload"
>
<div
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
:class="borderRadiusClass"
class="absolute inset-0 z-10 flex items-center justify-center invisible w-full h-full transition-all duration-300 ease-in-out opacity-0 rounded-xl bg-n-alpha-black1 group-hover/avatar:visible group-hover/avatar:opacity-100"
@click="handleUploadAvatar"
>
<Icon
@@ -117,7 +117,7 @@ const handleCreateAssistant = () => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-5xl mx-auto">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
@@ -140,9 +140,7 @@ const handleCreateAssistant = () => {
>
<Button
icon="i-lucide-chevron-down"
:variant="
showAssistantSwitcherDropdown ? 'faded' : 'ghost'
"
variant="ghost"
color="slate"
size="xs"
:disabled="isFetchingAssistants"
@@ -206,7 +204,7 @@ const handleCreateAssistant = () => {
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-5xl h-full mx-auto py-4">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
v-if="isFetching"
@@ -224,12 +222,11 @@ const handleCreateAssistant = () => {
<slot />
</div>
</main>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 px-4 pb-4">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="itemsPerPage"
class="max-w-[67rem]"
@update:current-page="handlePageChange"
/>
</footer>
@@ -27,7 +27,7 @@ const openBilling = () => {
<template>
<div
class="w-full max-w-5xl mx-auto h-full max-h-[448px] grid place-content-center"
class="w-full max-w-[60rem] mx-auto h-full max-h-[448px] grid place-content-center"
>
<BasePaywallModal
class="mx-auto"
@@ -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"
@@ -56,13 +56,8 @@ const selectedLabel = computed(() => {
});
const selectOption = option => {
if (selectedValue.value === option.value) {
selectedValue.value = '';
emit('update:modelValue', '');
} else {
selectedValue.value = option.value;
emit('update:modelValue', option.value);
}
selectedValue.value = option.value;
emit('update:modelValue', option.value);
open.value = false;
search.value = '';
};
@@ -79,8 +79,8 @@ defineExpose({
:aria-multiselectable="multiple"
>
<li
v-for="(option, index) in options"
:key="`${option.value}-${index}`"
v-for="option in options"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),
@@ -53,11 +53,6 @@ const props = defineProps({
default: 'lg',
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
},
position: {
type: String,
default: 'center',
validator: value => ['center', 'top'].includes(value),
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -66,7 +61,6 @@ const { t } = useI18n();
const dialogRef = ref(null);
const dialogContentRef = ref(null);
const isOpen = ref(false);
const maxWidthClass = computed(() => {
const classesMap = {
@@ -81,19 +75,13 @@ const maxWidthClass = computed(() => {
return classesMap[props.width] ?? 'max-w-md';
});
const positionClass = computed(() =>
props.position === 'top' ? 'dialog-position-top' : ''
);
const open = () => {
isOpen.value = true;
dialogRef.value?.showModal();
};
const close = () => {
emit('close');
dialogRef.value?.close();
isOpen.value = false;
};
const confirm = () => {
@@ -110,7 +98,6 @@ defineExpose({ open, close });
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="[
maxWidthClass,
positionClass,
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
]"
@close="close"
@@ -118,7 +105,7 @@ defineExpose({ open, close });
<OnClickOutside @trigger="close">
<form
ref="dialogContentRef"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-start align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
class="flex flex-col w-full h-auto gap-6 p-6 overflow-visible text-left align-middle transition-all duration-300 ease-in-out transform bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
@submit.prevent="confirm"
@click.stop
>
@@ -132,7 +119,7 @@ defineExpose({ open, close });
</p>
</slot>
</div>
<slot v-if="isOpen" />
<slot />
<!-- Dialog content will be injected here -->
<slot name="footer">
<div
@@ -169,9 +156,4 @@ defineExpose({ open, close });
dialog::backdrop {
@apply bg-n-alpha-black1 backdrop-blur-[4px];
}
.dialog-position-top {
margin-top: clamp(2rem, 5vh, 5rem);
margin-bottom: auto;
}
</style>
@@ -11,13 +11,7 @@ const props = defineProps({
type: Array,
default: () => [],
validator: value => {
return value.every(
item =>
item.action &&
item.value !== undefined &&
item.value !== null &&
item.label
);
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
@@ -28,10 +22,6 @@ const props = defineProps({
type: Number,
default: 20,
},
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: {
type: Boolean,
default: false,
@@ -52,17 +42,9 @@ const props = defineProps({
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
});
const emit = defineEmits(['action', 'search', 'empty']);
const emit = defineEmits(['action', 'search']);
const { t } = useI18n();
@@ -114,13 +96,9 @@ const filteredMenuSections = computed(() => {
});
const handleSearchInput = event => {
emit('search', event.target.value);
const isEmpty = hasSections.value
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
if (props.disableLocalFiltering) {
emit('search', event.target.value);
}
};
const handleAction = item => {
@@ -212,27 +190,22 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
@@ -241,9 +214,6 @@ onMounted(() => {
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" />
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
@@ -263,27 +233,22 @@ onMounted(() => {
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
@@ -293,9 +258,7 @@ onMounted(() => {
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div>
<slot name="footer" />
@@ -4,10 +4,6 @@ defineProps({
type: String,
default: '',
},
height: {
type: String,
default: 'max-h-96',
},
});
</script>
@@ -19,10 +15,7 @@ defineProps({
>
{{ title }}
</div>
<ul
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
:class="height"
>
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
<slot />
</ul>
</div>
@@ -50,7 +50,7 @@ const openLink = link => {
]"
>
<section
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80 z-20"
class="absolute top-full mt-6 ltr:left-0 rtl:right-0 outline outline-1 outline-n-weak bg-n-alpha-3 backdrop-blur-[100px] rounded-xl p-4 w-80"
>
<div
class="absolute -top-[0.77rem] ltr:left-12 rtl:right-12 w-6 h-6 ltr:rotate-45 rtl:-rotate-45 rtl:rounded-tr ltr:rounded-tl rtl:border-r ltr:border-l border-t border-n-weak bg-n-alpha-3 z-10"
@@ -39,45 +39,38 @@ const formatFilterValue = value => {
// Case 2: array map each item, use name if present, else the item itself
if (Array.isArray(value)) {
// Empty array
if (value.length === 0) return '';
return value.map(item => item?.name ?? item).join(', ');
}
// Case 3: empty object
if (typeof value === 'object' && Object.keys(value).length === 0) {
return '';
}
// Case 4: object with a "name" property return name
// Case 5: primitive (string, number, etc.) return as is
// Case 3: object with a "name" property return name
// Case 4: primitive (string, number, etc.) return as is
return value?.name ?? value;
};
</script>
<template>
<div class="flex flex-wrap items-center w-full gap-x-1.5 gap-y-2 mx-auto">
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-1.5 h-7"
class="inline-flex items-center gap-2 h-7"
>
<div
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer bg-n-button-color"
class="flex items-center h-full min-w-0 gap-1 px-2 py-1 text-xs border rounded-lg hover:bg-n-solid-2 max-w-72 border-n-weak hover:cursor-pointer"
@click="emit('openFilter')"
>
<span
class="lowercase whitespace-nowrap font-440 first-letter:capitalize text-n-slate-12"
class="lowercase whitespace-nowrap first-letter:capitalize text-n-slate-12"
>
{{ replaceUnderscoreWithSpace(filter.attributeKey) }}
</span>
<span class="px-1 text-xs text-n-slate-11 font-440 whitespace-nowrap">
<span class="px-1 text-xs text-n-slate-10 whitespace-nowrap">
{{ formatOperatorLabel(filter.filterOperator) }}
</span>
<span
v-if="formatFilterValue(filter.values)"
v-if="filter.values"
:title="formatFilterValue(filter.values)"
class="lowercase truncate text-n-slate-12 font-440"
class="lowercase truncate text-n-slate-12"
:class="{
'first-letter:capitalize': shouldCapitalizeFirstLetter(
filter.attributeKey
@@ -93,7 +86,7 @@ const formatFilterValue = value => {
"
>
<span
class="content-center h-full px-1 text-xs font-440 uppercase rounded-lg text-n-slate-11"
class="content-center h-full px-1 text-xs font-medium uppercase rounded-lg text-n-slate-10"
>
{{ filter.queryOperator }}
</span>
@@ -102,7 +95,7 @@ const formatFilterValue = value => {
</template>
<div
v-if="appliedFilters.length > maxVisibleFilters"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer font-440"
class="inline-flex items-center content-center px-1 text-xs rounded-lg text-n-slate-10 hover:text-n-slate-11 h-7 hover:cursor-pointer"
@click="emit('openFilter')"
>
{{ moreFiltersLabel }}
@@ -112,10 +105,9 @@ const formatFilterValue = value => {
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1 hover:!no-underline"
link
class="!px-1"
variant="ghost"
@click="emit('clearFilters')"
/>
<slot name="actions" />
</div>
</template>
@@ -50,12 +50,12 @@ const currentFilter = computed(() =>
);
const getOperator = (filter, selectedOperator) => {
const operatorFromOptions = filter?.filterOperators?.find(
const operatorFromOptions = filter.filterOperators.find(
operator => operator.value === selectedOperator
);
if (!operatorFromOptions) {
return filter?.filterOperators?.[0];
return filter.filterOperators[0];
}
return operatorFromOptions;
@@ -138,11 +138,7 @@ const validate = () => {
return !validationError.value;
};
const resetValidation = () => {
showErrors.value = false;
};
defineExpose({ validate, resetValidation });
defineExpose({ validate });
</script>
<template>
@@ -170,20 +166,18 @@ defineExpose({ validate, resetValidation });
<FilterSelect
v-model="filterOperator"
variant="ghost"
:options="currentFilter?.filterOperators"
:options="currentFilter.filterOperators"
/>
<template v-if="currentOperator?.hasInput">
<template v-if="currentOperator.hasInput">
<MultiSelect
v-if="inputType === 'multiSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-72"
/>
<SingleSelect
v-else-if="inputType === 'searchSelect'"
v-model="values"
:options="currentFilter.options"
dropdown-max-height="max-h-64"
/>
<SingleSelect
v-else-if="inputType === 'booleanSelect'"
@@ -1,14 +1,12 @@
<script setup>
import { useTemplateRef, onBeforeUnmount, computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBreakpoints } from '@vueuse/core';
import { useTrack } from 'dashboard/composables';
import { useStore } from 'dashboard/composables/store';
import { vOnClickOutside } from '@vueuse/components';
import { CONVERSATION_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useConversationFilterContext } from './provider.js';
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
import wootConstants from 'dashboard/constants/globals';
import Button from 'next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@@ -44,13 +42,6 @@ const DEFAULT_FILTER = {
const { t } = useI18n();
const store = useStore();
const breakpoints = useBreakpoints({
mobile: wootConstants.SMALL_SCREEN_BREAKPOINT,
});
const isSmallScreen = breakpoints.smaller('mobile');
const viewInModal = computed(() => isSmallScreen.value);
const resetFilter = () => {
filters.value = [{ ...DEFAULT_FILTER }];
};
@@ -113,80 +104,69 @@ const outsideClickHandler = [
<template>
<div
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start justify-center inset-0 px-2 pt-14 pb-8 overflow-y-auto overflow-x-hidden':
viewInModal,
}"
@click.self="viewInModal && emit('close')"
v-on-click-outside="outsideClickHandler"
class="z-40 max-w-3xl lg:w-[750px] overflow-visible w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl p-6 grid gap-6"
>
<div
v-on-click-outside="viewInModal ? [] : outsideClickHandler"
class="z-40 max-w-3xl xl:w-[750px] w-full border border-n-weak bg-n-alpha-3 backdrop-blur-[100px] shadow-lg rounded-xl py-6 grid gap-6"
:class="{ 'overflow-x-auto': viewInModal }"
>
<h3 class="text-base font-medium leading-6 text-n-slate-12 px-6">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView" class="px-6">
<div class="border-b border-n-strong pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<h3 class="text-base font-medium leading-6 text-n-slate-12">
{{ filterModalHeaderTitle }}
</h3>
<div v-if="props.isFolderView">
<div class="border-b border-n-weak pb-6">
<Input
v-model="folderNameLocal"
:label="t('FILTER.FOLDER_LABEL')"
:placeholder="t('FILTER.INPUT_PLACEHOLDER')"
/>
</div>
<ul class="grid gap-4 list-none px-6">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div
class="flex gap-2 justify-between ltr:pl-3 ltr:pr-6 rtl:pr-3 rtl:pl-6"
>
<Button sm ghost blue class="px-0" @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</div>
<ul class="grid gap-4 list-none">
<template v-for="(filter, index) in filters" :key="filter.id">
<ConditionRow
v-if="index === 0"
ref="conditionsRef"
:key="`filter-${filter.attributeKey}-0`"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:values="filter.values"
:filter-types="filterTypes"
:show-query-operator="false"
@remove="removeFilter(index)"
/>
<ConditionRow
v-else
:key="`filter-${filter.attributeKey}-${index}`"
ref="conditionsRef"
v-model:attribute-key="filter.attributeKey"
v-model:filter-operator="filter.filterOperator"
v-model:query-operator="filters[index - 1].queryOperator"
v-model:values="filter.values"
show-query-operator
:filter-types="filterTypes"
@remove="removeFilter(index)"
/>
</template>
</ul>
<div class="flex gap-2 justify-between">
<Button sm ghost blue @click="addFilter">
{{ $t('FILTER.ADD_NEW_FILTER') }}
</Button>
<div class="flex gap-2">
<Button sm faded slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
</Button>
<div class="flex gap-2">
<Button sm solid slate @click="resetFilter">
{{ t('FILTER.CLEAR_BUTTON_LABEL') }}
</Button>
<Button
v-if="isFolderView"
sm
solid
blue
:disabled="!folderNameLocal"
@click="updateSavedCustomViews"
>
{{ t('FILTER.UPDATE_BUTTON_LABEL') }}
</Button>
<Button v-else sm solid blue @click="validateAndSubmit">
{{ t('FILTER.SUBMIT_BUTTON_LABEL') }}
</Button>
</div>
</div>
</div>
</div>
@@ -108,10 +108,8 @@ export default {
<NextInput
v-model="name"
:placeholder="$t('FILTER.CUSTOM_VIEWS.ADD.PLACEHOLDER')"
:message="
v$.name.$error ? $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE') : ''
"
:message-type="v$.name.$error ? 'error' : 'info'"
:message="v$.name.$error && $t('FILTER.CUSTOM_VIEWS.ADD.ERROR_MESSAGE')"
:message-type="v$.name.$error && 'error'"
@blur="v$.name.$touch"
/>
<div class="flex flex-row justify-end w-full gap-2">
@@ -45,7 +45,7 @@ const { height } = useWindowSize();
const { height: dropdownHeight } = useElementBounding(dropdownRef);
const selectedOption = computed(() => {
return props.options?.find(o => o.value === selected.value) || {};
return props.options.find(o => o.value === selected.value) || {};
});
const iconToRender = computed(() => {
@@ -87,25 +87,18 @@ const updateSelected = newValue => {
</template>
<DropdownBody
ref="dropdownRef"
class="min-w-56 z-50"
class="min-w-48 z-50"
:class="dropdownPosition"
strong
>
<DropdownSection class="[&>ul]:max-h-72">
<template v-for="option in options" :key="option.value">
<li
v-if="option.disabled"
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
>
{{ option.label }}
</li>
<DropdownItem
v-else
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</template>
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.value"
:label="option.label"
:icon="option.icon"
@click="updateSelected(option.value)"
/>
</DropdownSection>
</DropdownBody>
</DropdownContainer>
@@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options, maxChips, dropdownMaxHeight } = defineProps({
const { options, maxChips } = defineProps({
options: {
type: Array,
required: true,
@@ -17,10 +17,6 @@ const { options, maxChips, dropdownMaxHeight } = defineProps({
type: Number,
default: 3,
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
});
const { t } = useI18n();
@@ -95,7 +91,7 @@ const toggleOption = option => {
<template #trigger="{ toggle }">
<button
v-if="hasItems"
class="bg-n-button-color outline outline-1 outline-n-container py-2 rounded-lg h-8 flex items-center px-0"
class="bg-n-alpha-2 py-2 rounded-lg h-8 flex items-center px-0"
@click="toggle"
>
<div
@@ -119,17 +115,15 @@ const toggleOption = option => {
<Icon icon="i-lucide-plus" />
</div>
</button>
<Button v-else sm slate solid @click="toggle">
<Button v-else sm slate faded @click="toggle">
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11 flex-shrink-0" />
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11 truncate">{{
t('COMBOBOX.PLACEHOLDER')
}}</span>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-48 z-50" strong>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<DropdownItem
v-for="option in options"
:key="option.id"
@@ -12,12 +12,10 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const {
options,
disableSearch,
disableDeselect,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
dropdownMaxHeight,
} = defineProps({
options: {
type: Array,
@@ -43,14 +41,6 @@ const {
type: String,
default: '',
},
dropdownMaxHeight: {
type: String,
default: 'max-h-80',
},
disableDeselect: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
@@ -73,8 +63,6 @@ const selectedItem = computed(() => {
const optionToSearch = Array.isArray(selected.value)
? selected.value[0]
: selected.value;
if (!optionToSearch) return null;
// extract the selected item from the options array
// this ensures that options like icon is also included
return options.find(option => option.id === optionToSearch.id);
@@ -89,7 +77,7 @@ const toggleSelected = option => {
};
if (selected.value && selected.value.id === optionToToggle.id) {
if (!disableDeselect) selected.value = null;
selected.value = null;
} else {
selected.value = optionToToggle;
}
@@ -136,7 +124,7 @@ const toggleSelected = option => {
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection :height="dropdownMaxHeight">
<DropdownSection class="[&>ul]:max-h-80">
<template v-if="searchResults.length">
<DropdownItem
v-for="option in searchResults"
@@ -9,12 +9,12 @@ export function useChannelIcon(inbox) {
'Channel::Sms': 'i-woot-sms',
'Channel::Telegram': 'i-woot-telegram',
'Channel::TwilioSms': 'i-woot-sms',
'Channel::TwitterProfile': 'i-woot-x',
'Channel::TwitterProfile': 'i-ri-twitter-x-fill',
'Channel::WebWidget': 'i-woot-website',
'Channel::Whatsapp': 'i-woot-whatsapp',
'Channel::Instagram': 'i-woot-instagram',
'Channel::Tiktok': 'i-woot-tiktok',
'Channel::Voice': 'i-woot-voice',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -22,7 +22,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-voice');
expect(icon).toBe('i-ri-phone-fill');
});
it('returns correct icon for Line channel', () => {
@@ -46,7 +46,7 @@ describe('useChannelIcon', () => {
it('returns correct icon for Twitter channel', () => {
const inbox = { channel_type: 'Channel::TwitterProfile' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-woot-x');
expect(icon).toBe('i-ri-twitter-x-fill');
});
it('returns correct icon for WebWidget channel', () => {
@@ -108,7 +108,7 @@ onMounted(() => {
<label
v-if="label"
:for="uniqueId"
class="mb-0.5 text-heading-3 text-n-slate-12"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
@@ -145,7 +145,7 @@ onMounted(() => {
/>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-label-small truncate transition-all duration-500 ease-in-out"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
@@ -1,71 +0,0 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: {
type: [Object, String],
required: true,
},
compact: {
type: Boolean,
default: false,
},
color: {
type: String,
default: 'slate',
validator: value =>
['slate', 'amber', 'teal', 'ruby', 'blue', 'iris'].includes(value),
},
});
const COLOR_CLASSES = {
slate: 'bg-n-label-color outline-n-label-border text-n-slate-12',
amber: 'bg-n-amber-2 outline-n-amber-4 text-n-amber-11',
teal: 'bg-n-teal-2 outline-n-teal-4 text-n-teal-11',
ruby: 'bg-n-ruby-2 outline-n-ruby-4 text-n-ruby-11',
blue: 'bg-n-blue-2 outline-n-blue-4 text-n-blue-11',
iris: 'bg-n-iris-2 outline-n-iris-4 text-n-iris-11',
};
const isStringLabel = computed(() => typeof props.label === 'string');
const labelTitle = computed(() => {
return isStringLabel.value ? props.label : props.label?.title;
});
const labelDescription = computed(() => {
return (!isStringLabel.value && props.label?.description) || '';
});
const labelColor = computed(() => {
return isStringLabel.value ? null : props.label.color;
});
const colorClasses = computed(() => COLOR_CLASSES[props.color]);
</script>
<template>
<div
:title="labelDescription"
class="rounded-lg -outline-offset-1 outline outline-1 inline-flex items-center flex-shrink-0"
:class="[
colorClasses,
compact ? 'px-1.5 h-6 gap-1 rounded-md' : 'px-2.5 h-8 gap-1.5 rounded-lg',
]"
>
<span
v-if="labelColor"
class="rounded-sm flex-shrink-0"
:class="compact ? 'size-1.5' : 'size-2'"
:style="{ background: labelColor }"
/>
<slot v-else name="icon" />
<span
class="whitespace-nowrap"
:class="compact ? 'text-label-small' : 'text-label !font-420'"
>
{{ labelTitle }}
</span>
<slot name="action" />
</div>
</template>
@@ -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,
@@ -43,7 +41,6 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
import { useBranding } from 'shared/composables/useBranding';
/**
* @typedef {Object} Attachment
@@ -142,9 +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) || {});
const { replaceInstallationName } = useBranding();
/**
* Computes the message variant based on props
@@ -168,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;
@@ -391,17 +381,13 @@ const shouldRenderMessage = computed(() => {
const isUnsupported = props.contentAttributes?.isUnsupported;
const isAnIntegrationMessage =
props.contentType === CONTENT_TYPES.INTEGRATIONS;
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
const hasExternalError = !!props.contentAttributes?.externalError;
return (
hasAttachments ||
props.content ||
isEmailContentType ||
isUnsupported ||
isAnIntegrationMessage ||
isFailedMessage ||
hasExternalError
isAnIntegrationMessage
);
});
@@ -438,18 +424,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 {
@@ -477,9 +451,6 @@ const avatarInfo = computed(() => {
});
const avatarTooltip = computed(() => {
if (props.contentAttributes?.externalEcho) {
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
}
if (avatarInfo.value.name === '') return '';
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
});
@@ -513,7 +484,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,
@@ -12,16 +12,11 @@ defineProps({
const emit = defineEmits(['retry']);
const { orientation, status, createdAt, content, attachments } =
useMessageContext();
const { orientation, status, createdAt } = useMessageContext();
const { t } = useI18n();
const canRetry = computed(() => {
const hasContent = content.value !== null;
const hasAttachments = attachments.value && attachments.value.length > 0;
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
});
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
</script>
<template>

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