Compare commits

..
Author SHA1 Message Date
Shivam Mishra dc60b6b6fa style: fix rubocop module length 2026-01-14 11:33:53 +05:30
Shivam Mishra 2484a4f074 feat: use new helpers 2026-01-14 11:32:01 +05:30
Shivam Mishra c179393a0b feat: add model based usage helper 2026-01-14 11:31:12 +05:30
Shivam Mishra ce822e1e58 test: captain api helper 2026-01-14 11:30:29 +05:30
Shivam Mishra 90e29883fa refactor: move captain config helpers to captain featureable 2026-01-13 20:59:36 +05:30
Shivam Mishra 0b69b13469 feat: always increment one credit usage for audio transcription 2026-01-13 20:44:13 +05:30
Shivam Mishra cca0cf740b feat: enable captain on sidebar 2026-01-13 20:08:09 +05:30
Shivam Mishra b67ba61bda feat: BYOK users consume a single credit only 2026-01-13 20:03:17 +05:30
Shivam Mishra 011bbca28f feat: run migration 2026-01-13 19:59:48 +05:30
Shivam Mishra b493870943 feat: backfill preferences 2026-01-13 19:50:33 +05:30
Shivam Mishra b66cd77c8d feat: always use configured model 2026-01-13 19:36:10 +05:30
Shivam Mishra 1da0a2ff0f feat: fetch correct multiplier based on the feature 2026-01-13 19:35:54 +05:30
Shivam Mishra 8d03354a4c feat: allow increment response to accept credits 2026-01-13 19:35:21 +05:30
Shivam Mishra eee6ccc6f5 feat: add feature key for preference lookup 2026-01-13 19:31:36 +05:30
Shivam Mishra ab1cb7af86 feat: add credit_multiplier_for to fetch the model multiplier 2026-01-13 19:29:59 +05:30
Shivam Mishra 7fba7b946e fix(captain): add operation whitelist to prevent method injection
- Add ALLOWED_OPERATIONS whitelist to validate operation parameter
- Prevent arbitrary method invocation via send()
- Refactor tone methods using metaprogramming to reduce duplication
- Add specs for invalid operations and injection attempts
2026-01-13 18:22:35 +05:30
Shivam MishraandGitHub f63cef20bf Merge branch 'develop' into feat/captain-editor-integration 2026-01-13 18:09:53 +05:30
Shivam MishraandGitHub 61425b6a3b feat: wire up credit usage (#13260) 2026-01-13 18:09:34 +05:30
82f5dbe6c1 feat: reply editor follow-up (#13106)
Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
2026-01-13 14:29:11 +05:30
Shivam MishraandGitHub 7f057127b0 Merge branch 'develop' into feat/captain-editor-integration 2026-01-13 13:42:30 +05:30
Shivam MishraandGitHub c38dfdb739 Merge branch 'develop' into feat/captain-editor-integration 2026-01-12 19:57:00 +05:30
Shivam Mishra ead5b46620 chore: conflict fixes 2026-01-12 15:41:07 +05:30
Shivam MishraandGitHub 772eeafecb Merge branch 'develop' into feat/captain-editor-integration 2026-01-12 14:04:47 +05:30
8f1cd32fab feat: separate service and controller for editor tasks (#13085)
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
2025-12-18 14:23:33 +05:30
Sivin VargheseandGitHub 1a95a99710 feat(CW-6116): Reply Editor AI Changes (#13030)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Fixes https://linear.app/chatwoot/issue/CW-5648/reply-editor-ai-changes
2025-12-17 13:40:57 +05:30
0fe2bf8647 feat: Rewrite APIs for ai editor message improvement (#13059)
Co-authored-by: Vishnu Narayanan <iamwishnu@gmail.com>
2025-12-15 18:38:21 +05:30
548 changed files with 7656 additions and 17643 deletions
+10 -10
View File
@@ -76,7 +76,7 @@ jobs:
bundle install
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -117,7 +117,7 @@ jobs:
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -144,11 +144,11 @@ jobs:
# Backend tests with parallelization
backend-tests:
<<: *defaults
parallelism: 18
parallelism: 16
steps:
- checkout
- node/install:
node-version: '24.13'
node-version: '24.12'
- node/install-pnpm
- node/install-packages:
pkg-manager: pnpm
@@ -350,12 +350,12 @@ jobs:
destination: coverage
build:
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
<<: *defaults
steps:
- run:
name: Legacy build aggregator
command: |
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
workflows:
version: 2
+1 -1
View File
@@ -10,7 +10,7 @@ services:
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '24.12.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
+1 -1
View File
@@ -11,7 +11,7 @@ services:
dockerfile: .devcontainer/Dockerfile
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '24.13.0'
NODE_VERSION: '24.12.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
-1
View File
@@ -276,4 +276,3 @@ AZURE_APP_SECRET=
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
# REDIS_ALFRED_SIZE=10
# REDIS_VELMA_SIZE=10
-2
View File
@@ -94,7 +94,6 @@ yarn-debug.log*
.vscode
.claude/settings.local.json
.cursor
.codex/
CLAUDE.local.md
# Histoire deployment
@@ -102,4 +101,3 @@ CLAUDE.local.md
.histoire
.pnpm-store/*
local/
Procfile.worktree
+1 -1
View File
@@ -1 +1 @@
24.13.0
24.12.0
-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'
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'
+17 -19
View File
@@ -126,8 +126,8 @@ GEM
jbuilder (~> 2)
rails (>= 4.2, < 7.2)
selectize-rails (~> 0.6)
ai-agents (0.9.0)
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)
@@ -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
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.0
4.9.2
@@ -112,25 +112,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
return if story_reply_attributes.blank?
@message.save_story_info(story_reply_attributes)
create_story_reply_attachment(story_reply_attributes['url'])
end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation
@@ -158,7 +139,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: message_type,
status: @outgoing_echo ? :delivered : :sent,
source_id: message_identifier,
content: message_content,
sender: @outgoing_echo ? nil : contact,
@@ -167,7 +147,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
}
}
params[:content_attributes][:external_echo] = true if @outgoing_echo
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
params
end
@@ -1,68 +0,0 @@
class V2::Reports::FirstResponseTimeDistributionBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
build_distribution
end
private
def build_distribution
results = fetch_aggregated_counts
map_to_channel_types(results)
end
def fetch_aggregated_counts
ReportingEvent
.where(account_id: account.id, name: 'first_response')
.where(range_condition)
.group(:inbox_id)
.select(
:inbox_id,
bucket_case_statements
)
end
def bucket_case_statements
<<~SQL.squish
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
SQL
end
def range_condition
range.present? ? { created_at: range } : {}
end
def inbox_channel_types
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
end
def map_to_channel_types(results)
results.each_with_object({}) do |row, hash|
channel_type = inbox_channel_types[row.inbox_id]
next unless channel_type
hash[channel_type] ||= empty_buckets
hash[channel_type]['0-1h'] += row.bucket_0_1h
hash[channel_type]['1-4h'] += row.bucket_1_4h
hash[channel_type]['4-8h'] += row.bucket_4_8h
hash[channel_type]['8-24h'] += row.bucket_8_24h
hash[channel_type]['24h+'] += row.bucket_24h_plus
end
end
def empty_buckets
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
end
end
@@ -1,65 +0,0 @@
class V2::Reports::InboxLabelMatrixBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account:, params:)
@account = account
@params = params
end
def build
{
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
matrix: build_matrix
}
end
private
def filtered_inboxes
@filtered_inboxes ||= begin
inboxes = account.inboxes
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
inboxes.order(:name).to_a
end
end
def filtered_labels
@filtered_labels ||= begin
labels = account.labels
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
labels.order(:title).to_a
end
end
def conversation_filter
filter = { account_id: account.id }
filter[:created_at] = range if range.present?
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
filter
end
def fetch_grouped_counts
label_names = filtered_labels.map(&:title)
return {} if label_names.empty?
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
.where(tags: { name: label_names })
.group('conversations.inbox_id', 'tags.name')
.count
end
def build_matrix
counts = fetch_grouped_counts
filtered_inboxes.map do |inbox|
filtered_labels.map do |label|
counts[[inbox.id, label.title]] || 0
end
end
end
end
@@ -1,79 +0,0 @@
class V2::Reports::OutgoingMessagesCountBuilder
include DateRangeHelper
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
send("build_by_#{params[:group_by]}")
end
private
def base_messages
account.messages.outgoing.unscope(:order).where(created_at: range)
end
def build_by_agent
counts = base_messages
.where(sender_type: 'User')
.where.not(sender_id: nil)
.group(:sender_id)
.count
user_names = account.users.where(id: counts.keys).index_by(&:id)
counts.map do |user_id, count|
user = user_names[user_id]
{ id: user_id, name: user&.name, outgoing_messages_count: count }
end
end
def build_by_team
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.where.not(conversations: { team_id: nil })
.group('conversations.team_id')
.count
team_names = account.teams.where(id: counts.keys).index_by(&:id)
counts.map do |team_id, count|
team = team_names[team_id]
{ id: team_id, name: team&.name, outgoing_messages_count: count }
end
end
def build_by_inbox
counts = base_messages
.group(:inbox_id)
.count
inbox_names = account.inboxes.where(id: counts.keys).index_by(&:id)
counts.map do |inbox_id, count|
inbox = inbox_names[inbox_id]
{ id: inbox_id, name: inbox&.name, outgoing_messages_count: count }
end
end
def build_by_label
counts = base_messages
.joins('INNER JOIN conversations ON messages.conversation_id = conversations.id')
.joins("INNER JOIN taggings ON taggings.taggable_id = conversations.id
AND taggings.taggable_type = 'Conversation' AND taggings.context = 'labels'")
.joins('INNER JOIN tags ON tags.id = taggings.tag_id')
.group('tags.name')
.count
label_ids = account.labels.where(title: counts.keys).index_by(&:title)
counts.map do |label_name, count|
label = label_ids[label_name]
{ id: label&.id, name: label_name, outgoing_messages_count: count }
end
end
end
@@ -24,11 +24,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def search
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
contacts = Current.account.contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
contacts = resolved_contacts.where(
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts = fetch_contacts_with_has_more(contacts)
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -141,24 +143,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
.per(RESULTS_PER_PAGE)
end
def fetch_contacts_with_has_more(contacts)
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
# Calculate offset manually to fetch one extra record for has_more check
offset = (@current_page.to_i - 1) * RESULTS_PER_PAGE
results = filtrate(contacts)
.includes(includes_hash)
.offset(offset)
.limit(RESULTS_PER_PAGE + 1)
.to_a
@has_more = results.size > RESULTS_PER_PAGE
results = results.first(RESULTS_PER_PAGE) if @has_more
@contacts_count = results.size
results
end
def build_contact_inbox
return if params[:inbox_id].blank?
@@ -70,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
@@ -113,15 +110,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -154,25 +142,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_columns(updates)
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
# rubocop:enable Rails/SkipsModelValidations
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
@current_page = params[:page] || 1
end
end
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
@@ -92,11 +92,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(*permitted_settings_attributes)
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
conversation_required_attributes: [])
end
def check_signup_enabled
@@ -115,5 +112,3 @@ class Api::V1::AccountsController < Api::BaseController
}
end
end
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
@@ -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
@@ -62,31 +62,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: bot_metrics
end
def inbox_label_matrix
builder = V2::Reports::InboxLabelMatrixBuilder.new(
account: Current.account,
params: inbox_label_matrix_params
)
render json: builder.build
end
def first_response_time_distribution
builder = V2::Reports::FirstResponseTimeDistributionBuilder.new(
account: Current.account,
params: first_response_time_distribution_params
)
render json: builder.build
end
OUTGOING_MESSAGES_ALLOWED_GROUP_BY = %w[agent team inbox label].freeze
def outgoing_messages_count
return head :unprocessable_entity unless OUTGOING_MESSAGES_ALLOWED_GROUP_BY.include?(params[:group_by])
builder = V2::Reports::OutgoingMessagesCountBuilder.new(Current.account, outgoing_messages_count_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@@ -164,28 +139,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
def inbox_label_matrix_params
{
since: params[:since],
until: params[:until],
inbox_ids: params[:inbox_ids],
label_ids: params[:label_ids]
}
end
def first_response_time_distribution_params
{
since: params[:since],
until: params[:until]
}
end
def outgoing_messages_count_params
{
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
end
@@ -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
-7
View File
@@ -1,7 +0,0 @@
# Inherits from ActionController::Base to skip all middleware,
# authentication, and callbacks. Used for health checks
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
def show
render json: { status: 'woot' }
end
end
@@ -42,20 +42,19 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
}
@allowed_configs = mapping.fetch(
@config,
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
)
end
end
@@ -1,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
+7 -6
View File
@@ -2,6 +2,13 @@
# No need to replicate the same values in two places
# ------- Premium Features ------- #
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
icon: 'icon-captain'
config_key: 'captain'
enterprise: true
saml:
name: 'SAML SSO'
description: 'Configuration for controlling SAML Single Sign-On availability'
@@ -41,12 +48,6 @@ help_center:
description: 'Allow agents to create help center articles and publish them in a portal.'
enabled: true
icon: 'icon-book-2-line'
captain:
name: 'Captain'
description: 'Enable AI-powered conversations with your customers.'
enabled: true
icon: 'icon-captain'
config_key: 'captain'
# ------- Communication Channels ------- #
live_chat:
+1 -1
View File
@@ -131,7 +131,7 @@ export default {
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
class="flex flex-col w-full h-screen min-h-0"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
@@ -13,6 +13,33 @@ class TasksAPI extends ApiClient {
super('captain/tasks', { accountScoped: true });
}
/**
* Processes an event using the Captain Tasks API.
* @param {Object} options - The options for the event.
* @param {string} [options.type='improve'] - The type of event to process.
* @param {string} [options.content] - The content of the event.
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the result of the event processing.
*/
processEvent({ type = 'improve', content, conversationId }, signal) {
// Route to appropriate endpoint based on type
if (type === 'summarize') {
return this.summarize(conversationId, signal);
}
if (type === 'reply_suggestion') {
return this.replySuggestion(conversationId, signal);
}
if (type === 'label_suggestion') {
return this.labelSuggestion(conversationId, signal);
}
// All other types are rewrite operations
return this.rewrite({ content, operation: type, conversationId }, signal);
}
/**
* Rewrites content with a specific operation.
* @param {Object} options - The rewrite options.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

@@ -107,39 +107,21 @@
--violet-11: 101 85 183;
--violet-12: 47 38 95;
--background-color: 247 247 247;
--surface-1: 254 254 254;
--surface-2: 255 255 255;
--surface-active: 255 255 255;
--background-input-box: 0, 0, 0, 0.03;
--text-blue: 1 22 44;
--text-purple: 2 4 49;
--text-amber: 37 24 1;
--background-color: 253 253 253;
--text-blue: 8 109 224;
--border-container: 236 236 236;
--border-strong: 226 227 231;
--border-strong: 235 235 235;
--border-weak: 234 234 234;
--border-blue-strong: 18 61 117;
--solid-1: 255 255 255;
--solid-2: 255 255 255;
--solid-3: 255 255 255;
--solid-active: 255 255 255;
--solid-amber: 255 228 181;
--solid-amber: 252 232 193;
--solid-blue: 218 236 255;
--solid-blue-2: 251 253 255;
--solid-iris: 230 231 255;
--solid-purple: 230 231 255;
--solid-red: 254 200 201;
--solid-amber-button: 255 221 141;
--card-color: 255 255 255;
--overlay: 0, 0, 0, 0.12;
--overlay-avatar: 255, 255, 255, 0.67;
--button-color: 255 255 255;
--button-hover-color: 255, 255, 255, 0.2;
--label-background: 247 247 247;
--label-border: 0, 0, 0, 0.04;
--alpha-1: 215, 215, 215, 0.22;
--alpha-2: 196, 197, 198, 0.22;
--alpha-1: 67, 67, 67, 0.06;
--alpha-2: 201, 202, 207, 0.15;
--alpha-3: 255, 255, 255, 0.96;
--black-alpha-1: 0, 0, 0, 0.12;
--black-alpha-2: 0, 0, 0, 0.04;
@@ -253,43 +235,25 @@
--violet-11: 169 153 236;
--violet-12: 226 221 254;
--background-color: 28 29 32;
--surface-1: 20 21 23;
--surface-2: 22 23 26;
--surface-active: 53 57 66;
--background-input-box: 255, 255, 255, 0.02;
--text-blue: 213 234 255;
--text-purple: 232 233 254;
--text-amber: 255 247 234;
--border-strong: 46 45 50;
--border-weak: 31 31 37;
--border-blue-strong: 201 226 255;
--background-color: 18 18 19;
--border-strong: 52 52 52;
--border-weak: 38 38 42;
--solid-1: 23 23 26;
--solid-2: 29 30 36;
--solid-3: 44 45 54;
--solid-active: 53 57 66;
--solid-amber: 56 50 41;
--solid-blue: 15 57 102;
--solid-blue-2: 26 29 35;
--solid-amber: 42 37 30;
--solid-blue: 16 49 91;
--solid-iris: 38 42 101;
--solid-purple: 51 51 107;
--solid-red: 90 33 34;
--solid-amber-button: 255 221 141;
--card-color: 28 30 34;
--overlay: 0, 0, 0, 0.4;
--overlay-avatar: 0, 0, 0, 0.05;
--button-color: 42 43 51;
--button-hover-color: 0, 0, 0, 0.15;
--label-background: 36 38 45;
--label-border: 255, 255, 255, 0.03;
--text-blue: 126 182 255;
--alpha-1: 35, 36, 42, 0.8;
--alpha-2: 147, 153, 176, 0.12;
--alpha-3: 33, 34, 38, 0.95;
--alpha-1: 36, 36, 36, 0.8;
--alpha-2: 139, 147, 182, 0.15;
--alpha-3: 36, 38, 45, 0.9;
--black-alpha-1: 0, 0, 0, 0.3;
--black-alpha-2: 0, 0, 0, 0.2;
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--border-container: 236, 236, 236, 0;
--white-alpha: 255, 255, 255, 0.1;
}
}
@@ -7,7 +7,7 @@
@apply bg-n-slate-3;
&::after {
@apply text-n-blue-11;
@apply text-n-blue-text;
}
}
}
@@ -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 },
});
@@ -64,6 +65,22 @@ const handleFetchInboxes = () => {
{{ name }}
</h3>
<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(
@@ -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
@@ -119,7 +119,7 @@ onMounted(() => {
)
"
:items="filteredTags"
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
@add="onClickAddTag"
/>
</div>
@@ -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 { useI18n } from 'vue-i18n';
const props = defineProps({
id: {
type: String,
@@ -18,22 +16,12 @@ const props = defineProps({
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
disabledMessage: {
type: String,
default: '',
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const handleChange = () => {
if (!props.isActive && !props.disabled) {
if (!props.isActive) {
emit('select', props.id);
}
};
@@ -41,11 +29,9 @@ const handleChange = () => {
<template>
<div
class="relative 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"
>
@@ -55,7 +41,6 @@ const handleChange = () => {
: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"
@change="handleChange"
@@ -64,23 +49,11 @@ const handleChange = () => {
<!-- Content -->
<div class="flex flex-col gap-3 items-start">
<div class="flex items-center gap-2">
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<span
v-if="disabled"
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-n-yellow-3 text-n-yellow-11"
>
{{
t(
'ASSIGNMENT_POLICY.AGENT_ASSIGNMENT_POLICY.FORM.ASSIGNMENT_ORDER.BALANCED.PREMIUM_BADGE'
)
}}
</span>
</div>
<h3 class="text-sm font-medium text-n-slate-12">
{{ label }}
</h3>
<p class="text-sm text-n-slate-11">
{{ disabled && disabledMessage ? disabledMessage : description }}
{{ description }}
</p>
</div>
</div>
@@ -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>
@@ -20,7 +20,7 @@ const handleButtonClick = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
@@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -174,7 +174,7 @@ const handleSubmit = async () => {
color="slate"
type="button"
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -251,7 +251,7 @@ watch(
color="slate"
type="button"
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<CompanyHeader
@@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -73,7 +73,7 @@ defineExpose({ dialogRef });
target="_blank"
rel="noopener noreferrer"
download="import-contacts-sample.csv"
class="text-n-blue-11"
class="text-n-blue-text"
>
{{
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
@@ -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 = () =>
@@ -5,7 +5,6 @@ import { useRoute } from 'vue-router';
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
const props = defineProps({
searchValue: { type: String, default: '' },
@@ -20,9 +19,6 @@ const props = defineProps({
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
useInfiniteScroll: { type: Boolean, default: false },
hasMore: { type: Boolean, default: false },
isLoadingMore: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -31,7 +27,6 @@ const emit = defineEmits([
'search',
'applyFilter',
'clearFilters',
'loadMore',
]);
const route = useRoute();
@@ -66,19 +61,11 @@ const updateCurrentPage = page => {
const openFilter = () => {
contactListHeaderWrapper.value?.onToggleFilters();
};
const showLoadMore = computed(() => {
return props.useInfiniteScroll && props.hasMore;
});
const showPagination = computed(() => {
return !props.useInfiniteScroll && props.showPaginationFooter;
});
</script>
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
>
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
@@ -107,14 +94,9 @@ const showPagination = computed(() => {
@open-filter="openFilter"
/>
<slot name="default" />
<ContactsLoadMore
v-if="showLoadMore"
:is-loading="isLoadingMore"
@load-more="emit('loadMore')"
/>
</div>
</main>
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
<PaginationFooter
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
@@ -1,28 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['loadMore']);
const { t } = useI18n();
</script>
<template>
<div class="flex justify-center py-4">
<Button
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
:is-loading="isLoading"
variant="faded"
color="slate"
size="sm"
@click="emit('loadMore')"
/>
</div>
</template>
@@ -130,7 +130,7 @@ const onMergeContacts = async () => {
variant="faded"
color="slate"
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="resetState"
/>
<Button
@@ -1,17 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="flex justify-between items-center px-4 py-4 w-full">
<div
class="flex justify-center items-center py-6 w-full custom-dashed-border"
>
<span class="text-sm text-n-slate-11">
{{ t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.NO_ATTRIBUTES') }}
</span>
</div>
</div>
</template>
@@ -1,56 +0,0 @@
<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 { ATTRIBUTE_TYPES } from './constants';
const props = defineProps({
attribute: {
type: Object,
required: true,
},
});
const emit = defineEmits(['delete']);
const iconByType = {
[ATTRIBUTE_TYPES.TEXT]: 'i-lucide-align-justify',
[ATTRIBUTE_TYPES.CHECKBOX]: 'i-lucide-circle-check-big',
[ATTRIBUTE_TYPES.LIST]: 'i-lucide-list',
[ATTRIBUTE_TYPES.DATE]: 'i-lucide-calendar',
[ATTRIBUTE_TYPES.LINK]: 'i-lucide-link',
[ATTRIBUTE_TYPES.NUMBER]: 'i-lucide-hash',
};
const attributeIcon = computed(() => {
const typeKey = props.attribute.type?.toLowerCase();
return iconByType[typeKey] || 'i-lucide-align-justify';
});
const handleDelete = () => {
emit('delete', props.attribute);
};
</script>
<template>
<div class="flex justify-between items-center px-4 py-3 w-full">
<div class="flex gap-3 items-center">
<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-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-sm text-n-slate-11">{{ attribute.value }}</span>
</div>
</div>
<div class="flex gap-2 items-center">
<Button icon="i-lucide-trash" sm slate ghost @click.stop="handleDelete" />
</div>
</div>
</template>
@@ -1,186 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ConversationRequiredAttributeItem from 'dashboard/components-next/ConversationWorkflow/ConversationRequiredAttributeItem.vue';
import ConversationRequiredEmpty from 'dashboard/components-next/Conversation/ConversationRequiredEmpty.vue';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
const props = defineProps({
isEnabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click']);
const router = useRouter();
const { t } = useI18n();
const { currentAccount, accountId, isOnChatwootCloud, updateAccount } =
useAccount();
const [showDropdown, toggleDropdown] = useToggle(false);
const [isSaving, toggleSaving] = useToggle(false);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const currentUser = useMapGetter('getCurrentUser');
const isSuperAdmin = computed(() => currentUser.value.type === 'SuperAdmin');
const showPaywall = computed(() => !props.isEnabled && isOnChatwootCloud.value);
const i18nKey = computed(() =>
isOnChatwootCloud.value ? 'PAYWALL' : 'ENTERPRISE_PAYWALL'
);
const goToBillingSettings = () => {
router.push({
name: 'billing_settings_index',
params: { accountId: accountId.value },
});
};
const handleClick = () => {
emit('click');
};
const selectedAttributeKeys = computed(
() => currentAccount.value?.settings?.conversation_required_attributes || []
);
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
action: 'add',
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
}))
);
const attributeOptions = computed(() => {
const selectedKeysSet = new Set(selectedAttributeKeys.value);
return allAttributeOptions.value.filter(
attribute => !selectedKeysSet.has(attribute.value)
);
});
const conversationRequiredAttributes = computed(() => {
const attributeMap = new Map(
allAttributeOptions.value.map(attr => [attr.value, attr])
);
return selectedAttributeKeys.value
.map(key => attributeMap.get(key))
.filter(Boolean);
});
const handleAddAttributesClick = event => {
event.stopPropagation();
toggleDropdown();
};
const saveRequiredAttributes = async keys => {
try {
toggleSaving(true);
await updateAccount(
{ conversation_required_attributes: keys },
{ silent: true }
);
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.SUCCESS'));
} catch (error) {
useAlert(t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.SAVE.ERROR'));
} finally {
toggleSaving(false);
toggleDropdown(false);
}
};
const handleAttributeAction = ({ value }) => {
if (!value || isSaving.value) return;
const updatedKeys = Array.from(
new Set([...selectedAttributeKeys.value, value])
);
saveRequiredAttributes(updatedKeys);
};
const closeDropdown = () => {
toggleDropdown(false);
};
const handleDelete = attribute => {
if (isSaving.value) return;
const updatedKeys = selectedAttributeKeys.value.filter(
key => key !== attribute.value
);
saveRequiredAttributes(updatedKeys);
};
</script>
<template>
<div
v-if="isEnabled || showPaywall"
class="flex flex-col w-full outline-1 outline outline-n-container rounded-xl bg-n-solid-2 divide-y divide-n-weak"
@click="handleClick"
>
<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-base font-medium text-n-slate-12">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.TITLE') }}
</h3>
<p class="mb-0 text-sm text-n-slate-11">
{{ $t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.DESCRIPTION') }}
</p>
</div>
<div v-if="isEnabled" v-on-clickaway="closeDropdown" class="relative">
<Button
icon="i-lucide-circle-plus"
:label="$t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.TITLE')"
:is-loading="isSaving"
:disabled="isSaving || attributeOptions.length === 0"
@click="handleAddAttributesClick"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="attributeOptions"
show-search
:search-placeholder="
$t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.ADD.SEARCH_PLACEHOLDER'
)
"
class="top-full mt-1 w-52 ltr:right-0 rtl:left-0"
@action="handleAttributeAction"
/>
</div>
</div>
</div>
<template v-if="isEnabled">
<ConversationRequiredEmpty
v-if="conversationRequiredAttributes.length === 0"
/>
<ConversationRequiredAttributeItem
v-for="attribute in conversationRequiredAttributes"
:key="attribute.value"
:attribute="attribute"
@delete="handleDelete"
/>
</template>
<BasePaywallModal
v-else
class="mx-auto my-8"
feature-prefix="CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES"
:i18n-key="i18nKey"
:is-on-chatwoot-cloud="isOnChatwootCloud"
:is-super-admin="isSuperAdmin"
@upgrade="goToBillingSettings"
/>
</div>
</template>
@@ -1,248 +0,0 @@
<script setup>
import { ref, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, url, helpers } from '@vuelidate/validators';
import { getRegexp } from 'shared/helpers/Validators';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import TextArea from 'next/textarea/TextArea.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ChoiceToggle from 'dashboard/components-next/input/ChoiceToggle.vue';
import { ATTRIBUTE_TYPES } from './constants';
const emit = defineEmits(['submit']);
const { t } = useI18n();
const dialogRef = ref(null);
const visibleAttributes = ref([]);
const formValues = reactive({});
const conversationContext = ref(null);
const placeholders = computed(() => ({
text: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.TEXT'),
number: t(
'CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.NUMBER'
),
link: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LINK'),
date: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.DATE'),
list: t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.PLACEHOLDERS.LIST'),
}));
const getPlaceholder = type => placeholders.value[type] || '';
const validationRules = computed(() => {
const rules = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LINK) {
rules[attribute.value] = { required, url };
} else if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
// Checkbox doesn't need validation - any selection is valid
rules[attribute.value] = {};
} else {
rules[attribute.value] = { required };
if (attribute.regexPattern) {
rules[attribute.value].regexValidation = helpers.withParams(
{ regexCue: attribute.regexCue },
value => !value || getRegexp(attribute.regexPattern).test(value)
);
}
}
});
return rules;
});
const v$ = useVuelidate(validationRules, formValues);
const getErrorMessage = attributeKey => {
const field = v$.value[attributeKey];
if (!field || !field.$error) return '';
if (field.url && field.url.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (field.regexValidation && field.regexValidation.$invalid) {
return (
field.regexValidation.$params?.regexCue ||
t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
);
}
if (field.required && field.required.$invalid) {
return t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}
return '';
};
const isFormComplete = computed(() =>
visibleAttributes.value.every(attribute => {
const value = formValues[attribute.value];
// For checkbox attributes, ensure the agent has explicitly selected a value
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return formValues[attribute.value] !== null;
}
// For other attribute types, check for valid non-empty values
return value !== undefined && value !== null && String(value).trim() !== '';
})
);
const comboBoxOptions = computed(() => {
const options = {};
visibleAttributes.value.forEach(attribute => {
if (attribute.type === ATTRIBUTE_TYPES.LIST) {
options[attribute.value] = (attribute.attributeValues || []).map(
option => ({
value: option,
label: option,
})
);
}
});
return options;
});
const close = () => {
dialogRef.value?.close();
conversationContext.value = null;
v$.value.$reset();
};
const open = (attributes = [], initialValues = {}, context = null) => {
visibleAttributes.value = attributes;
conversationContext.value = context;
// Clear existing formValues
Object.keys(formValues).forEach(key => {
delete formValues[key];
});
// Initialize form values
attributes.forEach(attribute => {
const presetValue = initialValues[attribute.value];
if (presetValue !== undefined && presetValue !== null) {
formValues[attribute.value] = presetValue;
} else {
// For checkbox attributes, initialize to null to avoid pre-selection
// For other attributes, initialize to empty string
formValues[attribute.value] =
attribute.type === ATTRIBUTE_TYPES.CHECKBOX ? null : '';
}
});
v$.value.$reset();
dialogRef.value?.open();
};
const handleConfirm = async () => {
v$.value.$touch();
if (v$.value.$invalid) {
return;
}
emit('submit', {
attributes: { ...formValues },
context: conversationContext.value,
});
close();
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
width="lg"
:title="t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.TITLE')"
:description="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.DESCRIPTION')
"
:confirm-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.RESOLVE')
"
:cancel-button-label="
t('CONVERSATION_WORKFLOW.REQUIRED_ATTRIBUTES.MODAL.ACTIONS.CANCEL')
"
:disable-confirm-button="!isFormComplete"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div
v-for="attribute in visibleAttributes"
:key="attribute.value"
class="flex flex-col gap-2"
>
<div class="flex justify-between items-center">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ attribute.label }}
</label>
</div>
<template v-if="attribute.type === ATTRIBUTE_TYPES.TEXT">
<TextArea
v-model="formValues[attribute.value]"
class="w-full"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.TEXT)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.NUMBER">
<Input
v-model="formValues[attribute.value]"
type="number"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.NUMBER)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LINK">
<Input
v-model="formValues[attribute.value]"
type="url"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LINK)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.DATE">
<Input
v-model="formValues[attribute.value]"
type="date"
size="md"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.DATE)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
@blur="v$[attribute.value].$touch"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.LIST">
<ComboBox
v-model="formValues[attribute.value]"
:options="comboBoxOptions[attribute.value]"
:placeholder="getPlaceholder(ATTRIBUTE_TYPES.LIST)"
:message="getErrorMessage(attribute.value)"
:message-type="v$[attribute.value].$error ? 'error' : 'info'"
:has-error="v$[attribute.value].$error"
class="w-full"
/>
</template>
<template v-else-if="attribute.type === ATTRIBUTE_TYPES.CHECKBOX">
<ChoiceToggle v-model="formValues[attribute.value]" />
</template>
</div>
</div>
</Dialog>
</template>
@@ -1,8 +0,0 @@
export const ATTRIBUTE_TYPES = {
TEXT: 'text',
NUMBER: 'number',
LINK: 'link',
DATE: 'date',
LIST: 'list',
CHECKBOX: 'checkbox',
};
@@ -128,7 +128,7 @@ const handleInputUpdate = async () => {
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
!isEditingView,
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
'truncate hover:text-n-brand text-n-blue-11':
'truncate hover:text-n-brand text-n-blue-text':
isEditingView && isAttributeTypeLink,
}"
@click="toggleEditValue(!isEditingView)"
@@ -5,7 +5,6 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: { type: String, default: '' },
editorKey: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
@@ -97,7 +96,6 @@ watch(
]"
>
<WootEditor
:editor-id="editorKey"
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
@@ -154,13 +152,6 @@ watch(
}
}
}
.ProseMirror-menubar {
width: fit-content !important;
position: relative !important;
top: unset !important;
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
}
}
}
}
@@ -37,7 +37,7 @@ defineProps({
<div
class="flex flex-col items-center justify-end w-full h-full pb-20"
:class="{
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
showBackdrop,
}"
>
@@ -48,12 +48,14 @@ defineProps({
}"
>
<div class="flex flex-col items-center justify-center gap-3">
<h2 class="text-3xl font-medium text-center text-n-slate-12">
<h2
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
>
{{ title }}
</h2>
<p
v-if="subtitle"
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
@@ -126,7 +126,7 @@ const handleClick = id => {
<CardLayout>
<div class="flex justify-between w-full gap-1">
<span
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
@click="handleClick(id)"
>
{{ title }}
@@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
@click="handleClick(slug)"
>
{{ categoryTitleWithIcon }}
@@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
<div class="w-full max-w-[60rem] mx-auto lg:px-6">
<div
@@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
</span>
<span
v-if="isDefault"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
>
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
</span>
@@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
variant="faded"
color="slate"
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
const notificationDetails = computed(() => {
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
const [icon = '', color = 'text-n-blue-11'] =
const [icon = '', color = 'text-n-blue-text'] =
NOTIFICATION_TYPES_MAPPING[type] || [];
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
});
@@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
: 'i-lucide-alarm-clock-off'
"
class="flex-shrink-0 size-4"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
/>
<span
class="text-xs font-medium truncate"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
>
{{ snoozedText }}
</span>
@@ -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>
@@ -44,12 +44,6 @@ const emit = defineEmits([
const { t } = useI18n();
const attachmentId = ref(0);
const generateUid = () => {
attachmentId.value += 1;
return `attachment-${attachmentId.value}`;
};
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
@@ -182,8 +176,7 @@ const onPaste = e => {
.filter(file => file.size > 0)
.forEach(file => {
const { name, type, size } = file;
// Add unique ID for clipboard-pasted files
onFileUpload({ file, name, type, size, id: generateUid() });
onFileUpload({ file, name, type, size });
});
};
@@ -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';
@@ -7,7 +7,6 @@ import {
appendSignature,
removeSignature,
getEffectiveChannelType,
stripUnsupportedMarkdown,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
@@ -37,7 +36,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([
@@ -49,8 +47,6 @@ const emit = defineEmits([
'createConversation',
]);
const DEFAULT_FORMATTING = 'Context::Default';
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
@@ -58,13 +54,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,
@@ -202,22 +198,10 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
showInboxesDropdown.value = true;
};
const stripMessageFormatting = channelType => {
if (!state.message || !channelType) return;
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
};
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
// Strip unsupported formatting when changing the target inbox
if (channelType) {
const newChannelType = getEffectiveChannelType(channelType, medium);
stripMessageFormatting(newChannelType);
}
emit('updateTargetInbox', { ...rest, channelType, medium });
state.message = '';
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
@@ -237,9 +221,7 @@ const removeSignatureFromMessage = () => {
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
stripMessageFormatting(DEFAULT_FORMATTING);
state.message = '';
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -342,68 +324,67 @@ const shouldShowMessageEditor = computed(() => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
>
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<MessageEditor
v-if="shouldShowMessageEditor"
v-model="state.message"
:message-signature="messageSignature"
:send-with-signature="sendWithSignature"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
:medium="targetInbox?.medium || ''"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
</div>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
:attached-files="state.attachedFiles"
@@ -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)"
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
@@ -6,6 +6,7 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
hasErrors: { type: Boolean, default: false },
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
@@ -23,14 +24,14 @@ const modelValue = defineModel({
</script>
<template>
<div class="flex-1 h-full">
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
<Editor
:key="editorKey"
v-model="modelValue"
:editor-key="editorKey"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
@@ -103,11 +103,11 @@ const STYLE_CONFIG = {
solid:
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-blue-11 hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-11 outline-n-brand',
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text outline-n-brand',
ghost:
'text-n-blue-11 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
link: 'text-n-blue-text hover:enabled:underline focus-visible:underline outline-transparent',
},
ruby: {
solid:
@@ -133,7 +133,7 @@ const STYLE_CONFIG = {
},
slate: {
solid:
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
faded:
'bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent',
outline:
@@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
@@ -141,7 +141,7 @@ const onClickCancel = () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
@click="onClickCancel"
/>
<Button
@@ -169,7 +169,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -254,7 +254,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -220,7 +220,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -18,9 +18,7 @@ const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
@@ -39,7 +37,7 @@ defineExpose({ dialogRef });
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@@ -100,7 +100,7 @@ const handleSubmit = async () => {
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -116,7 +116,7 @@ watch(
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
@@ -34,7 +34,7 @@ const handleImgClick = () => {
<template>
<div
data-testid="changelog-card"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
:class="{
'animate-fade-out pointer-events-none': isDismissing,
'hover:shadow': isActive,
@@ -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 = '';
};
@@ -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>
@@ -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,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;
@@ -77,12 +77,12 @@ const queryOperatorOptions = computed(() => {
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
value: 'and',
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-11' }),
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
},
{
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
value: 'or',
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
},
];
});
@@ -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'"
@@ -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();
@@ -127,7 +123,7 @@ const toggleOption = option => {
</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"
@@ -82,7 +82,7 @@ export function useOperators() {
hasInput: !NO_INPUT_OPTS.includes(value),
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
icon: h('span', {
class: `${filterOperatorIcon[value]} !text-n-blue-11`,
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
}),
};
return acc;
@@ -1,45 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps({
modelValue: {
type: Boolean,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const options = computed(() => [
{ label: t('CHOICE_TOGGLE.YES'), value: true },
{ label: t('CHOICE_TOGGLE.NO'), value: false },
]);
const handleSelect = value => {
emit('update:modelValue', value);
};
</script>
<template>
<div
class="flex gap-4 items-center px-4 py-2.5 w-full rounded-lg divide-x transition-colors bg-n-solid-1 outline outline-1 outline-n-weak hover:outline-n-slate-6 focus-within:outline-n-brand divide-n-weak"
>
<div
v-for="option in options"
:key="option.value"
class="flex flex-1 gap-2 justify-center items-center"
>
<label class="inline-flex gap-2 items-center text-base cursor-pointer">
<input
type="radio"
:value="option.value"
:checked="modelValue === option.value"
class="size-4 accent-n-blue-9 text-n-blue-9"
@change="handleSelect(option.value)"
/>
<span class="text-sm text-n-slate-12">{{ option.label }}</span>
</label>
</div>
</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;
@@ -313,7 +303,6 @@ const componentToRender = computed(() => {
const instagramSharedTypes = [
ATTACHMENT_TYPES.STORY_MENTION,
ATTACHMENT_TYPES.IG_STORY,
ATTACHMENT_TYPES.IG_STORY_REPLY,
ATTACHMENT_TYPES.IG_POST,
];
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
@@ -391,17 +380,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 +423,6 @@ function handleReplyTo() {
}
const avatarInfo = computed(() => {
if (props.contentAttributes?.externalEcho) {
const { name, avatar_url, channel_type, medium } = inbox.value;
const iconName = avatar_url
? null
: getInboxIconByType(channel_type, medium);
return {
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
src: avatar_url || '',
iconName,
};
}
// If no sender, return bot info
if (!props.sender) {
return {
@@ -477,9 +450,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 +483,7 @@ provideMessageContext({
<div
v-if="shouldRenderMessage"
:id="`message${props.id}`"
class="flex w-full mb-2 message-bubble-container"
class="flex mb-2 w-full message-bubble-container"
:data-message-id="props.id"
:class="[
flexOrientationClass,
@@ -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>
@@ -163,7 +163,7 @@ const getInReplyToMessage = parentMessage => {
</script>
<template>
<ul class="px-4 bg-n-surface-1">
<ul class="px-4 bg-n-background">
<slot name="beforeAll" />
<template v-for="(message, index) in allMessages" :key="message.id">
<slot
@@ -7,7 +7,6 @@ import { emitter } from 'shared/helpers/mitt';
import { useMessageContext } from '../provider.js';
import { useI18n } from 'vue-i18n';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
@@ -81,7 +80,7 @@ const replyToPreview = computed(() => {
const { content, attachments } = inReplyTo.value;
if (content) return new MessageFormatter(content).formattedMessage;
if (content) return content;
if (attachments?.length) {
const firstAttachment = attachments[0];
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
@@ -108,10 +107,9 @@ const replyToPreview = computed(() => {
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
@click="scrollToMessage"
>
<div
v-dompurify-html="replyToPreview"
class="prose prose-bubble line-clamp-2"
/>
<span class="break-all line-clamp-2">
{{ replyToPreview }}
</span>
</div>
<slot />
<MessageMeta
@@ -1,26 +1,19 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageContext } from '../provider.js';
import Icon from 'next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
import { MESSAGE_VARIANTS } from '../constants';
const emit = defineEmits(['error']);
const { t } = useI18n();
const { variant, content, contentAttributes, attachments } =
useMessageContext();
const { variant, content, attachments } = useMessageContext();
const attachment = computed(() => {
return attachments.value[0];
});
const isStoryReply = computed(() => {
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
});
const hasImgStoryError = ref(false);
const hasVideoStoryError = ref(false);
@@ -45,9 +38,6 @@ const onVideoLoadError = () => {
<template>
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
<p v-if="isStoryReply" class="mb-1 text-xs text-n-slate-11">
{{ t('COMPONENTS.FILE_BUBBLE.INSTAGRAM_STORY_REPLY') }}
</p>
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
<img
v-if="!hasImgStoryError"
@@ -22,7 +22,7 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
</div>
</div>
</template>
@@ -18,8 +18,12 @@ defineProps({
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
<Button label="Call us" slate class="!text-n-blue-text w-full" />
<Button
label="Visit our website"
slate
class="!text-n-blue-text w-full"
/>
</div>
</div>
</template>
@@ -1,27 +1,9 @@
<script setup>
import { computed } from 'vue';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
import BaseBubble from './Base.vue';
const { inboxId } = useMessageContext();
const { isAFacebookInbox, isAnInstagramChannel, isATiktokChannel } = useInbox(
inboxId.value
);
const unsupportedMessageKey = computed(() => {
if (isAFacebookInbox.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_FACEBOOK';
if (isAnInstagramChannel.value)
return 'CONVERSATION.UNSUPPORTED_MESSAGE_INSTAGRAM';
if (isATiktokChannel.value) return 'CONVERSATION.UNSUPPORTED_MESSAGE_TIKTOK';
return 'CONVERSATION.UNSUPPORTED_MESSAGE';
});
</script>
<template>
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
{{ $t(unsupportedMessageKey) }}
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
</BaseBubble>
</template>
@@ -52,7 +52,6 @@ export const ATTACHMENT_TYPES = {
EMBED: 'embed',
IG_POST: 'ig_post',
IG_STORY: 'ig_story',
IG_STORY_REPLY: 'ig_story_reply',
};
export const CONTENT_TYPES = {
@@ -71,7 +71,7 @@ const pageInfo = computed(() => {
<template>
<div
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
>
<div class="flex items-center gap-3">
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
@@ -8,7 +8,6 @@ const props = defineProps({
type: String,
required: true,
},
// eslint-disable-next-line vue/no-unused-properties
active: {
type: Boolean,
default: false,
@@ -25,7 +24,10 @@ const reauthorizationRequired = computed(() => {
</script>
<template>
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
<span
class="size-5 grid place-content-center rounded-full bg-n-alpha-2"
:class="{ 'bg-n-solid-blue': active }"
>
<ChannelIcon :inbox="inbox" class="size-3" />
</span>
<div class="flex-1 truncate min-w-0">{{ label }}</div>
@@ -39,7 +39,7 @@ const toggleSidebar = () => {
<div
v-if="!isConversationRoute"
id="mobile-sidebar-launcher"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
:class="[
{
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
@@ -1,6 +1,6 @@
<script setup>
import { h, ref, computed, onMounted } from 'vue';
import { provideSidebarContext, useSidebarResize } from './provider';
import { provideSidebarContext } from './provider';
import { useAccount } from 'dashboard/composables/useAccount';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { useMapGetter } from 'dashboard/composables/store';
@@ -8,8 +8,6 @@ import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useWindowSize, useEventListener } from '@vueuse/core';
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -17,9 +15,8 @@ import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import SidebarChangelogCard from './SidebarChangelogCard.vue';
import SidebarChangelogButton from './SidebarChangelogButton.vue';
import YearInReviewBanner from '../year-in-review/YearInReviewBanner.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
@@ -46,22 +43,6 @@ const { t } = useI18n();
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const isRTL = useMapGetter('accounts/isRTL');
const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const hasAdvancedAssignment = computed(() => {
return isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.ADVANCED_ASSIGNMENT
);
});
const toggleShortcutModalFn = show => {
if (show) {
@@ -78,85 +59,11 @@ const expandedItem = ref(null);
const setExpandedItem = name => {
expandedItem.value = expandedItem.value === name ? null : name;
};
const {
sidebarWidth,
isCollapsed,
setSidebarWidth,
saveWidth,
snapToCollapsed,
snapToExpanded,
COLLAPSED_THRESHOLD,
} = useSidebarResize();
// On mobile, sidebar is always expanded (flyout mode)
const isEffectivelyCollapsed = computed(
() => !isMobile.value && isCollapsed.value
);
// Resize handle logic
const isResizing = ref(false);
const startX = ref(0);
const startWidth = ref(0);
provideSidebarContext({
expandedItem,
setExpandedItem,
isCollapsed: isEffectivelyCollapsed,
sidebarWidth,
isResizing,
});
// Get clientX from mouse or touch event
const getClientX = event =>
event.touches ? event.touches[0].clientX : event.clientX;
const onResizeStart = event => {
isResizing.value = true;
startX.value = getClientX(event);
startWidth.value = sidebarWidth.value;
Object.assign(document.body.style, {
cursor: 'col-resize',
userSelect: 'none',
});
// Prevent default to avoid scrolling on touch
event.preventDefault();
};
const onResizeMove = event => {
if (!isResizing.value) return;
const delta = isRTL.value
? startX.value - getClientX(event)
: getClientX(event) - startX.value;
setSidebarWidth(startWidth.value + delta);
};
const onResizeEnd = () => {
if (!isResizing.value) return;
isResizing.value = false;
Object.assign(document.body.style, { cursor: '', userSelect: '' });
// Snap to collapsed state if below threshold
if (sidebarWidth.value < COLLAPSED_THRESHOLD) {
snapToCollapsed();
} else {
saveWidth();
}
};
const onResizeHandleDoubleClick = () => {
if (isCollapsed.value) snapToExpanded();
else snapToCollapsed();
};
// Support both mouse and touch events
useEventListener(document, 'mousemove', onResizeMove);
useEventListener(document, 'mouseup', onResizeEnd);
useEventListener(document, 'touchmove', onResizeMove, { passive: false });
useEventListener(document, 'touchend', onResizeEnd);
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabelsOnSidebar');
const teams = useMapGetter('teams/getMyTeams');
@@ -286,7 +193,6 @@ const menuItems = computed(() => {
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
component: leafProps =>
h(ChannelLeaf, {
@@ -305,7 +211,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[8px] rounded-sm`,
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute('label_conversations', {
@@ -433,7 +339,7 @@ const menuItems = computed(() => {
name: `${label.title}-${label.id}`,
label: label.title,
icon: h('span', {
class: `size-[8px] rounded-sm`,
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
to: accountScopedRoute(
@@ -579,12 +485,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-briefcase',
to: accountScopedRoute('general_settings_index'),
},
// {
// name: 'Settings Captain',
// label: t('SIDEBAR.CAPTAIN_AI'),
// icon: 'i-woot-captain',
// to: accountScopedRoute('captain_settings_index'),
// },
{
name: 'Settings Captain',
label: t('SIDEBAR.CAPTAIN_AI'),
icon: 'i-woot-captain',
to: accountScopedRoute('captain_settings_index'),
},
{
name: 'Settings Agents',
label: t('SIDEBAR.AGENTS'),
@@ -597,16 +503,12 @@ const menuItems = computed(() => {
icon: 'i-lucide-users',
to: accountScopedRoute('settings_teams_list'),
},
...(hasAdvancedAssignment.value
? [
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
]
: []),
{
name: 'Settings Agent Assignment',
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
icon: 'i-lucide-user-cog',
to: accountScopedRoute('assignment_policy_index'),
},
{
name: 'Settings Inboxes',
label: t('SIDEBAR.INBOXES'),
@@ -628,7 +530,7 @@ const menuItems = computed(() => {
{
name: 'Settings Automation',
label: t('SIDEBAR.AUTOMATION'),
icon: 'i-lucide-repeat',
icon: 'i-lucide-workflow',
to: accountScopedRoute('automation_list'),
},
{
@@ -673,12 +575,6 @@ const menuItems = computed(() => {
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('sla_list'),
},
{
name: 'Conversation Workflow',
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
icon: 'i-lucide-workflow',
to: accountScopedRoute('conversation_workflow_index'),
},
{
name: 'Settings Security',
label: t('SIDEBAR.SECURITY'),
@@ -703,56 +599,32 @@ const menuItems = computed(() => {
closeMobileSidebar,
{ ignore: ['#mobile-sidebar-launcher'] },
]"
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
'transition-transform duration-200 ease-out md:transition-[width]':
!isResizing,
},
]"
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
>
<section
class="grid"
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
>
<div
class="flex gap-2 items-center min-w-0"
:class="{
'justify-center px-1': isEffectivelyCollapsed,
'px-2': !isEffectivelyCollapsed,
}"
>
<template v-if="isEffectivelyCollapsed">
<SidebarAccountSwitcher
is-collapsed
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
<template v-else>
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</template>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex gap-2 items-center px-2 min-w-0">
<div class="grid flex-shrink-0 place-content-center size-6">
<Logo class="size-4" />
</div>
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarAccountSwitcher
class="flex-grow -mx-1 min-w-0"
@show-create-account-modal="emit('showCreateAccountModal')"
/>
</div>
<div
class="flex gap-2"
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
>
<div class="flex gap-2 px-2">
<RouterLink
v-if="!isEffectivelyCollapsed"
:to="{ name: 'search' }"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
<span class="flex-grow text-start text-n-slate-10">
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
</span>
<span
@@ -761,41 +633,21 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<RouterLink
v-else
:to="{ name: 'search' }"
class="flex items-center justify-center size-8 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out hover:bg-n-alpha-2 dark:hover:bg-n-slate-9/30"
:title="t('COMBOBOX.SEARCH_PLACEHOLDER')"
>
<span class="i-lucide-search size-4 text-n-slate-11" />
</RouterLink>
<ComposeConversation align-position="right" @close="onComposeClose">
<template #trigger="{ toggle, isOpen }">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="dark:hover:!bg-n-slate-9/30"
:class="[
isEffectivelyCollapsed
? '!size-8 !outline-n-weak !text-n-slate-11'
: '!h-7 !outline-n-weak !text-n-slate-11',
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
]"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="onComposeOpen(toggle)"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
>
<ul
class="flex flex-col gap-1 m-0 list-none min-w-0"
:class="{ 'items-center': isEffectivelyCollapsed }"
>
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
<ul class="flex flex-col gap-1.5 m-0 list-none">
<SidebarGroup
v-for="item in menuItems"
:key="item.name"
@@ -804,46 +656,22 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
>
<div
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
/>
<YearInReviewBanner />
<SidebarChangelogCard
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
!isEffectivelyCollapsed
"
/>
<SidebarChangelogButton
v-if="
isOnChatwootCloud &&
!isACustomBrandedInstance &&
isEffectivelyCollapsed
"
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
/>
<div
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
>
<SidebarProfileMenu
:is-collapsed="isEffectivelyCollapsed"
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
</div>
</section>
<!-- Resize Handle (desktop only) -->
<div
class="hidden md:block absolute top-0 h-full w-1 cursor-col-resize z-40 ltr:right-0 rtl:left-0 group"
@mousedown="onResizeStart"
@touchstart="onResizeStart"
@dblclick="onResizeHandleDoubleClick"
>
<div
class="absolute top-0 h-full w-px ltr:right-0 rtl:left-0 bg-transparent group-hover:bg-n-brand transition-colors"
:class="{ 'bg-n-brand': isResizing }"
/>
</div>
</aside>
</template>

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