Merge pull request #215 from fazer-ai/chore/merge-upstream-4.11.0
Chore/merge upstream 4.11.0
This commit is contained in:
commit
fdde54f1a2
@ -144,7 +144,7 @@ jobs:
|
|||||||
# Backend tests with parallelization
|
# Backend tests with parallelization
|
||||||
backend-tests:
|
backend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
parallelism: 16
|
parallelism: 18
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
@ -350,12 +350,12 @@ jobs:
|
|||||||
destination: coverage
|
destination: coverage
|
||||||
|
|
||||||
build:
|
build:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
steps:
|
steps:
|
||||||
- run:
|
- run:
|
||||||
name: Legacy build aggregator
|
name: Legacy build aggregator
|
||||||
command: |
|
command: |
|
||||||
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
echo "All main jobs passed; build job kept only for GitHub required check compatibility."
|
||||||
|
|
||||||
workflows:
|
workflows:
|
||||||
version: 2
|
version: 2
|
||||||
|
|||||||
@ -276,6 +276,7 @@ AZURE_APP_SECRET=
|
|||||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||||
|
|
||||||
# REDIS_ALFRED_SIZE=10
|
# REDIS_ALFRED_SIZE=10
|
||||||
|
# REDIS_VELMA_SIZE=10
|
||||||
|
|
||||||
# Baileys API Whatsapp provider
|
# Baileys API Whatsapp provider
|
||||||
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
BAILEYS_PROVIDER_DEFAULT_CLIENT_NAME=Chatwoot
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -94,6 +94,7 @@ yarn-debug.log*
|
|||||||
.vscode
|
.vscode
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.cursor
|
.cursor
|
||||||
|
.codex/
|
||||||
CLAUDE.local.md
|
CLAUDE.local.md
|
||||||
|
|
||||||
# Histoire deployment
|
# Histoire deployment
|
||||||
@ -101,3 +102,4 @@ CLAUDE.local.md
|
|||||||
.histoire
|
.histoire
|
||||||
.pnpm-store/*
|
.pnpm-store/*
|
||||||
local/
|
local/
|
||||||
|
Procfile.worktree
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@ -4,6 +4,11 @@
|
|||||||
|
|
||||||
- **Setup**: `bundle install && pnpm install`
|
- **Setup**: `bundle install && pnpm install`
|
||||||
- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev`
|
- **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 JS/Vue**: `pnpm eslint` / `pnpm eslint:fix`
|
||||||
- **Lint Ruby**: `bundle exec rubocop -a`
|
- **Lint Ruby**: `bundle exec rubocop -a`
|
||||||
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
- **Test JS**: `pnpm test` or `pnpm test:watch`
|
||||||
@ -50,6 +55,13 @@
|
|||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
- 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
|
- 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
|
## Commit Messages
|
||||||
|
|
||||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||||
@ -86,3 +98,7 @@ Practical checklist for any change impacting core logic or public APIs
|
|||||||
- When renaming/moving shared code, mirror the change in `enterprise/` to prevent drift.
|
- 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.
|
- 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/`.
|
- 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.
|
||||||
|
|||||||
4
Gemfile
4
Gemfile
@ -191,12 +191,14 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.7.0'
|
gem 'ai-agents'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.8.2'
|
gem 'ruby_llm', '>= 1.8.2'
|
||||||
gem 'ruby_llm-schema'
|
gem 'ruby_llm-schema'
|
||||||
|
|
||||||
|
gem 'cld3', '~> 3.7'
|
||||||
|
|
||||||
# OpenTelemetry for LLM observability
|
# OpenTelemetry for LLM observability
|
||||||
gem 'opentelemetry-sdk'
|
gem 'opentelemetry-sdk'
|
||||||
gem 'opentelemetry-exporter-otlp'
|
gem 'opentelemetry-exporter-otlp'
|
||||||
|
|||||||
38
Gemfile.lock
38
Gemfile.lock
@ -126,8 +126,8 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.7.0)
|
ai-agents (0.9.0)
|
||||||
ruby_llm (~> 1.8.2)
|
ruby_llm (~> 1.9.1)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
activesupport (>= 6.0.0)
|
activesupport (>= 6.0.0)
|
||||||
@ -186,6 +186,7 @@ GEM
|
|||||||
byebug (11.1.3)
|
byebug (11.1.3)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
cld3 (3.7.0)
|
||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
@ -297,7 +298,7 @@ GEM
|
|||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
faker (3.2.0)
|
faker (3.2.0)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.1)
|
faraday (2.14.1)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
@ -308,12 +309,12 @@ GEM
|
|||||||
hashie
|
hashie
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
faraday-net_http (3.4.0)
|
faraday-net_http (3.4.2)
|
||||||
net-http (>= 0.5.0)
|
net-http (~> 0.5)
|
||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.2.1)
|
faraday-retry (2.4.0)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday_middleware-aws-sigv4 (1.0.1)
|
faraday_middleware-aws-sigv4 (1.0.1)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
@ -464,7 +465,7 @@ GEM
|
|||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
json (2.13.2)
|
json (2.18.1)
|
||||||
json_refs (0.1.8)
|
json_refs (0.1.8)
|
||||||
hana
|
hana
|
||||||
json_schemer (0.2.24)
|
json_schemer (0.2.24)
|
||||||
@ -539,11 +540,11 @@ GEM
|
|||||||
net-imap
|
net-imap
|
||||||
net-pop
|
net-pop
|
||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.1.0)
|
||||||
maxminddb (0.1.22)
|
maxminddb (0.1.22)
|
||||||
meta_request (0.8.3)
|
meta_request (0.8.5)
|
||||||
rack-contrib (>= 1.1, < 3)
|
rack-contrib (>= 1.1, < 3)
|
||||||
railties (>= 3.0.0, < 8)
|
railties (>= 3.0.0, < 9)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
mime-types (3.4.1)
|
mime-types (3.4.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
@ -558,12 +559,12 @@ GEM
|
|||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.8.0)
|
multi_xml (0.8.0)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
multipart-post (2.3.0)
|
multipart-post (2.4.1)
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
neighbor (0.2.3)
|
neighbor (0.2.3)
|
||||||
activerecord (>= 5.2)
|
activerecord (>= 5.2)
|
||||||
net-http (0.6.0)
|
net-http (0.9.1)
|
||||||
uri
|
uri (>= 0.11.1)
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.20)
|
net-imap (0.4.20)
|
||||||
@ -676,7 +677,7 @@ GEM
|
|||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.2.3)
|
rack (3.2.5)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-contrib (2.5.0)
|
rack-contrib (2.5.0)
|
||||||
@ -826,7 +827,7 @@ GEM
|
|||||||
ruby2ruby (2.5.0)
|
ruby2ruby (2.5.0)
|
||||||
ruby_parser (~> 3.1)
|
ruby_parser (~> 3.1)
|
||||||
sexp_processor (~> 4.6)
|
sexp_processor (~> 4.6)
|
||||||
ruby_llm (1.8.2)
|
ruby_llm (1.9.2)
|
||||||
base64
|
base64
|
||||||
event_stream_parser (~> 1)
|
event_stream_parser (~> 1)
|
||||||
faraday (>= 1.10.0)
|
faraday (>= 1.10.0)
|
||||||
@ -970,7 +971,7 @@ GEM
|
|||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.0.4)
|
||||||
uniform_notifier (1.17.0)
|
uniform_notifier (1.17.0)
|
||||||
uri (1.0.4)
|
uri (1.1.1)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (5.2.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
@ -1005,7 +1006,7 @@ GEM
|
|||||||
working_hours (1.4.1)
|
working_hours (1.4.1)
|
||||||
activesupport (>= 3.2)
|
activesupport (>= 3.2)
|
||||||
tzinfo
|
tzinfo
|
||||||
zeitwerk (2.6.17)
|
zeitwerk (2.7.4)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
arm64-darwin-20
|
arm64-darwin-20
|
||||||
@ -1025,7 +1026,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.7.0)
|
ai-agents
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
@ -1039,6 +1040,7 @@ DEPENDENCIES
|
|||||||
bullet
|
bullet
|
||||||
bundle-audit
|
bundle-audit
|
||||||
byebug
|
byebug
|
||||||
|
cld3 (~> 3.7)
|
||||||
climate_control
|
climate_control
|
||||||
commonmarker
|
commonmarker
|
||||||
csv-safe
|
csv-safe
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
4.10.0
|
4.11.0
|
||||||
|
|||||||
@ -112,6 +112,25 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
return if story_reply_attributes.blank?
|
return if story_reply_attributes.blank?
|
||||||
|
|
||||||
@message.save_story_info(story_reply_attributes)
|
@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
|
end
|
||||||
|
|
||||||
def build_conversation
|
def build_conversation
|
||||||
@ -139,6 +158,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
account_id: conversation.account_id,
|
account_id: conversation.account_id,
|
||||||
inbox_id: conversation.inbox_id,
|
inbox_id: conversation.inbox_id,
|
||||||
message_type: message_type,
|
message_type: message_type,
|
||||||
|
status: @outgoing_echo ? :delivered : :sent,
|
||||||
source_id: message_identifier,
|
source_id: message_identifier,
|
||||||
content: message_content,
|
content: message_content,
|
||||||
sender: @outgoing_echo ? nil : contact,
|
sender: @outgoing_echo ? nil : contact,
|
||||||
@ -147,6 +167,7 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
params[:content_attributes][:external_echo] = true if @outgoing_echo
|
||||||
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
params[:content_attributes][:is_unsupported] = true if message_is_unsupported?
|
||||||
params
|
params
|
||||||
end
|
end
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
class V2::Reports::FirstResponseTimeDistributionBuilder
|
||||||
|
include DateRangeHelper
|
||||||
|
|
||||||
|
attr_reader :account, :params
|
||||||
|
|
||||||
|
def initialize(account:, params:)
|
||||||
|
@account = account
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
build_distribution
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_distribution
|
||||||
|
results = fetch_aggregated_counts
|
||||||
|
map_to_channel_types(results)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_aggregated_counts
|
||||||
|
ReportingEvent
|
||||||
|
.where(account_id: account.id, name: 'first_response')
|
||||||
|
.where(range_condition)
|
||||||
|
.group(:inbox_id)
|
||||||
|
.select(
|
||||||
|
:inbox_id,
|
||||||
|
bucket_case_statements
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bucket_case_statements
|
||||||
|
<<~SQL.squish
|
||||||
|
COUNT(CASE WHEN value < 3600 THEN 1 END) AS bucket_0_1h,
|
||||||
|
COUNT(CASE WHEN value >= 3600 AND value < 14400 THEN 1 END) AS bucket_1_4h,
|
||||||
|
COUNT(CASE WHEN value >= 14400 AND value < 28800 THEN 1 END) AS bucket_4_8h,
|
||||||
|
COUNT(CASE WHEN value >= 28800 AND value < 86400 THEN 1 END) AS bucket_8_24h,
|
||||||
|
COUNT(CASE WHEN value >= 86400 THEN 1 END) AS bucket_24h_plus
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def range_condition
|
||||||
|
range.present? ? { created_at: range } : {}
|
||||||
|
end
|
||||||
|
|
||||||
|
def inbox_channel_types
|
||||||
|
@inbox_channel_types ||= account.inboxes.pluck(:id, :channel_type).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def map_to_channel_types(results)
|
||||||
|
results.each_with_object({}) do |row, hash|
|
||||||
|
channel_type = inbox_channel_types[row.inbox_id]
|
||||||
|
next unless channel_type
|
||||||
|
|
||||||
|
hash[channel_type] ||= empty_buckets
|
||||||
|
hash[channel_type]['0-1h'] += row.bucket_0_1h
|
||||||
|
hash[channel_type]['1-4h'] += row.bucket_1_4h
|
||||||
|
hash[channel_type]['4-8h'] += row.bucket_4_8h
|
||||||
|
hash[channel_type]['8-24h'] += row.bucket_8_24h
|
||||||
|
hash[channel_type]['24h+'] += row.bucket_24h_plus
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_buckets
|
||||||
|
{ '0-1h' => 0, '1-4h' => 0, '4-8h' => 0, '8-24h' => 0, '24h+' => 0 }
|
||||||
|
end
|
||||||
|
end
|
||||||
65
app/builders/v2/reports/inbox_label_matrix_builder.rb
Normal file
65
app/builders/v2/reports/inbox_label_matrix_builder.rb
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
class V2::Reports::InboxLabelMatrixBuilder
|
||||||
|
include DateRangeHelper
|
||||||
|
|
||||||
|
attr_reader :account, :params
|
||||||
|
|
||||||
|
def initialize(account:, params:)
|
||||||
|
@account = account
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def build
|
||||||
|
{
|
||||||
|
inboxes: filtered_inboxes.map { |inbox| { id: inbox.id, name: inbox.name } },
|
||||||
|
labels: filtered_labels.map { |label| { id: label.id, title: label.title } },
|
||||||
|
matrix: build_matrix
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filtered_inboxes
|
||||||
|
@filtered_inboxes ||= begin
|
||||||
|
inboxes = account.inboxes
|
||||||
|
inboxes = inboxes.where(id: params[:inbox_ids]) if params[:inbox_ids].present?
|
||||||
|
inboxes.order(:name).to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_labels
|
||||||
|
@filtered_labels ||= begin
|
||||||
|
labels = account.labels
|
||||||
|
labels = labels.where(id: params[:label_ids]) if params[:label_ids].present?
|
||||||
|
labels.order(:title).to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_filter
|
||||||
|
filter = { account_id: account.id }
|
||||||
|
filter[:created_at] = range if range.present?
|
||||||
|
filter[:inbox_id] = params[:inbox_ids] if params[:inbox_ids].present?
|
||||||
|
filter
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_grouped_counts
|
||||||
|
label_names = filtered_labels.map(&:title)
|
||||||
|
return {} if label_names.empty?
|
||||||
|
|
||||||
|
ActsAsTaggableOn::Tagging
|
||||||
|
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
|
||||||
|
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
|
||||||
|
.where(taggable_type: 'Conversation', context: 'labels', conversations: conversation_filter)
|
||||||
|
.where(tags: { name: label_names })
|
||||||
|
.group('conversations.inbox_id', 'tags.name')
|
||||||
|
.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_matrix
|
||||||
|
counts = fetch_grouped_counts
|
||||||
|
filtered_inboxes.map do |inbox|
|
||||||
|
filtered_labels.map do |label|
|
||||||
|
counts[[inbox.id, label.title]] || 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
79
app/builders/v2/reports/outgoing_messages_count_builder.rb
Normal file
79
app/builders/v2/reports/outgoing_messages_count_builder.rb
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
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,13 +24,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
def search
|
def search
|
||||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||||
|
|
||||||
contacts = resolved_contacts.where(
|
contacts = Current.account.contacts.where(
|
||||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
'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}%"
|
search: "%#{params[:q].strip}%"
|
||||||
)
|
)
|
||||||
@contacts = fetch_contacts(contacts)
|
@contacts = fetch_contacts_with_has_more(contacts)
|
||||||
@contacts_count = @contacts.total_count
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
@ -143,6 +141,24 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||||||
.per(RESULTS_PER_PAGE)
|
.per(RESULTS_PER_PAGE)
|
||||||
end
|
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
|
def build_contact_inbox
|
||||||
return if params[:inbox_id].blank?
|
return if params[:inbox_id].blank?
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController # rubocop:disable Metrics/ClassLength
|
||||||
include Events::Types
|
include Events::Types
|
||||||
include DateRangeHelper
|
include DateRangeHelper
|
||||||
include HmacConcern
|
include HmacConcern
|
||||||
@ -70,8 +70,11 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
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
|
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||||
|
@conversation.account.increment_email_sent_count
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -110,6 +113,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_last_seen
|
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?
|
||||||
|
|
||||||
dispatch_messages_read_event if assignee?
|
dispatch_messages_read_event if assignee?
|
||||||
|
|
||||||
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
|
||||||
@ -146,12 +158,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_last_seen_on_conversation(last_seen_at, update_assignee)
|
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
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
@conversation.update_column(:agent_last_seen_at, last_seen_at)
|
@conversation.update_columns(updates)
|
||||||
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
|
|
||||||
# rubocop:enable Rails/SkipsModelValidations
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
end
|
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
|
def set_conversation_status
|
||||||
@conversation.status = params[:status]
|
@conversation.status = params[:status]
|
||||||
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
|
||||||
|
|||||||
@ -92,8 +92,11 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
params.permit(*permitted_settings_attributes)
|
||||||
conversation_required_attributes: [])
|
end
|
||||||
|
|
||||||
|
def permitted_settings_attributes
|
||||||
|
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
@ -112,3 +115,5 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Api::V1::AccountsController.prepend_mod_with('Api::V1::AccountsSettings')
|
||||||
|
|||||||
@ -35,12 +35,11 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def transcript
|
def transcript
|
||||||
if conversation.present? && conversation.contact.present? && conversation.contact.email.present?
|
return head :too_many_requests if conversation.blank?
|
||||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
return head :payment_required unless conversation.account.email_transcript_enabled?
|
||||||
conversation,
|
return head :too_many_requests unless conversation.account.within_email_rate_limit?
|
||||||
conversation.contact.email
|
|
||||||
)&.deliver_later
|
send_transcript_email
|
||||||
end
|
|
||||||
head :ok
|
head :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -79,6 +78,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
|
|
||||||
private
|
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)
|
def trigger_typing_event(event)
|
||||||
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: conversation, user: @contact)
|
||||||
end
|
end
|
||||||
|
|||||||
@ -62,6 +62,31 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
render json: bot_metrics
|
render json: bot_metrics
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def generate_csv(filename, template)
|
def generate_csv(filename, template)
|
||||||
@ -139,4 +164,28 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
def conversation_metrics
|
def conversation_metrics
|
||||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -6,12 +6,8 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@user = User.from_email(params[:email])
|
@user = User.from_email(params[:email])
|
||||||
if @user
|
@user&.send_reset_password_instructions
|
||||||
@user.send_reset_password_instructions
|
build_response(I18n.t('messages.reset_password'), 200)
|
||||||
build_response(I18n.t('messages.reset_password_success'), 200)
|
|
||||||
else
|
|
||||||
build_response(I18n.t('messages.reset_password_failure'), 404)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
7
app/controllers/health_controller.rb
Normal file
7
app/controllers/health_controller.rb
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Inherits from ActionController::Base to skip all middleware,
|
||||||
|
# authentication, and callbacks. Used for health checks
|
||||||
|
class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
|
||||||
|
def show
|
||||||
|
render json: { status: 'woot' }
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -42,19 +42,20 @@ 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],
|
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
|
||||||
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
|
||||||
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
|
||||||
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
|
'email' => %w[MAILER_INBOUND_EMAIL_DOMAIN ACCOUNT_EMAILS_LIMIT ACCOUNT_EMAILS_PLAN_LIMITS],
|
||||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||||
'slack' => %w[SLACK_CLIENT_ID SLACK_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],
|
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET TIKTOK_API_VERSION],
|
||||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
'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]
|
'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]
|
||||||
}
|
}
|
||||||
|
|
||||||
@allowed_configs = mapping.fetch(
|
@allowed_configs = mapping.fetch(
|
||||||
@config,
|
@config,
|
||||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE]
|
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE WIDGET_TOKEN_EXPIRY]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
35
app/controllers/webhooks/shopify_controller.rb
Normal file
35
app/controllers/webhooks/shopify_controller.rb
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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
|
||||||
@ -2,13 +2,6 @@
|
|||||||
# No need to replicate the same values in two places
|
# No need to replicate the same values in two places
|
||||||
|
|
||||||
# ------- Premium Features ------- #
|
# ------- 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:
|
saml:
|
||||||
name: 'SAML SSO'
|
name: 'SAML SSO'
|
||||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||||
@ -48,6 +41,12 @@ help_center:
|
|||||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||||
enabled: true
|
enabled: true
|
||||||
icon: 'icon-book-2-line'
|
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 ------- #
|
# ------- Communication Channels ------- #
|
||||||
live_chat:
|
live_chat:
|
||||||
|
|||||||
@ -131,7 +131,7 @@ export default {
|
|||||||
<div
|
<div
|
||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex flex-col w-full h-screen min-h-0"
|
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
|
|||||||
107
app/javascript/dashboard/api/captain/tasks.js
Normal file
107
app/javascript/dashboard/api/captain/tasks.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
/* global axios */
|
||||||
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client for the Captain Tasks API.
|
||||||
|
* @extends ApiClient
|
||||||
|
*/
|
||||||
|
class TasksAPI extends ApiClient {
|
||||||
|
/**
|
||||||
|
* Creates a new TasksAPI instance.
|
||||||
|
*/
|
||||||
|
constructor() {
|
||||||
|
super('captain/tasks', { accountScoped: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites content with a specific operation.
|
||||||
|
* @param {Object} options - The rewrite options.
|
||||||
|
* @param {string} options.content - The content to rewrite.
|
||||||
|
* @param {string} options.operation - The rewrite operation (fix_spelling_grammar, casual, professional, etc).
|
||||||
|
* @param {string} [options.conversationId] - The conversation ID for context (required for 'improve').
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||||
|
* @returns {Promise} A promise that resolves with the rewritten content.
|
||||||
|
*/
|
||||||
|
rewrite({ content, operation, conversationId }, signal) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/rewrite`,
|
||||||
|
{
|
||||||
|
content,
|
||||||
|
operation,
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarizes a conversation.
|
||||||
|
* @param {string} conversationId - The conversation ID to summarize.
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||||
|
* @returns {Promise} A promise that resolves with the summary.
|
||||||
|
*/
|
||||||
|
summarize(conversationId, signal) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/summarize`,
|
||||||
|
{
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a reply suggestion for a conversation.
|
||||||
|
* @param {string} conversationId - The conversation ID.
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||||
|
* @returns {Promise} A promise that resolves with the reply suggestion.
|
||||||
|
*/
|
||||||
|
replySuggestion(conversationId, signal) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/reply_suggestion`,
|
||||||
|
{
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets label suggestions for a conversation.
|
||||||
|
* @param {string} conversationId - The conversation ID.
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||||
|
* @returns {Promise} A promise that resolves with label suggestions.
|
||||||
|
*/
|
||||||
|
labelSuggestion(conversationId, signal) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/label_suggestion`,
|
||||||
|
{
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a follow-up message to continue refining a previous task result.
|
||||||
|
* @param {Object} options - The follow-up options.
|
||||||
|
* @param {Object} options.followUpContext - The follow-up context from a previous task.
|
||||||
|
* @param {string} options.message - The follow-up message/request from the user.
|
||||||
|
* @param {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
|
||||||
|
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
|
||||||
|
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
|
||||||
|
*/
|
||||||
|
followUp({ followUpContext, message, conversationId }, signal) {
|
||||||
|
return axios.post(
|
||||||
|
`${this.url}/follow_up`,
|
||||||
|
{
|
||||||
|
follow_up_context: followUpContext,
|
||||||
|
message,
|
||||||
|
conversation_display_id: conversationId,
|
||||||
|
},
|
||||||
|
{ signal }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TasksAPI();
|
||||||
@ -1,81 +0,0 @@
|
|||||||
/* global axios */
|
|
||||||
|
|
||||||
import ApiClient from '../ApiClient';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents the data object for a OpenAI hook.
|
|
||||||
* @typedef {Object} ConversationMessageData
|
|
||||||
* @property {string} [tone] - The tone of the message.
|
|
||||||
* @property {string} [content] - The content of the message.
|
|
||||||
* @property {string} [conversation_display_id] - The display ID of the conversation (optional).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A client for the OpenAI API.
|
|
||||||
* @extends ApiClient
|
|
||||||
*/
|
|
||||||
class OpenAIAPI extends ApiClient {
|
|
||||||
/**
|
|
||||||
* Creates a new OpenAIAPI instance.
|
|
||||||
*/
|
|
||||||
constructor() {
|
|
||||||
super('integrations', { accountScoped: true });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The conversation events supported by the API.
|
|
||||||
* @type {string[]}
|
|
||||||
*/
|
|
||||||
this.conversation_events = [
|
|
||||||
'summarize',
|
|
||||||
'reply_suggestion',
|
|
||||||
'label_suggestion',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The message events supported by the API.
|
|
||||||
* @type {string[]}
|
|
||||||
*/
|
|
||||||
this.message_events = ['rephrase'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Processes an event using the OpenAI API.
|
|
||||||
* @param {Object} options - The options for the event.
|
|
||||||
* @param {string} [options.type='rephrase'] - The type of event to process.
|
|
||||||
* @param {string} [options.content] - The content of the event.
|
|
||||||
* @param {string} [options.tone] - The tone of the event.
|
|
||||||
* @param {string} [options.conversationId] - The ID of the conversation to process the event for.
|
|
||||||
* @param {string} options.hookId - The ID of the hook to use for processing the event.
|
|
||||||
* @returns {Promise} A promise that resolves with the result of the event processing.
|
|
||||||
*/
|
|
||||||
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
|
|
||||||
/**
|
|
||||||
* @type {ConversationMessageData}
|
|
||||||
*/
|
|
||||||
let data = {
|
|
||||||
tone,
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Always include conversation_display_id when available for session tracking
|
|
||||||
if (conversationId) {
|
|
||||||
data.conversation_display_id = conversationId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For conversation-level events, only send conversation_display_id
|
|
||||||
if (this.conversation_events.includes(type)) {
|
|
||||||
data = {
|
|
||||||
conversation_display_id: conversationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
|
|
||||||
event: {
|
|
||||||
name: type,
|
|
||||||
data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new OpenAIAPI();
|
|
||||||
BIN
app/javascript/dashboard/assets/images/auth/signup-bg.jpg
Normal file
BIN
app/javascript/dashboard/assets/images/auth/signup-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@ -94,21 +94,52 @@
|
|||||||
--gray-11: 100 100 100;
|
--gray-11: 100 100 100;
|
||||||
--gray-12: 32 32 32;
|
--gray-12: 32 32 32;
|
||||||
|
|
||||||
--background-color: 253 253 253;
|
--violet-1: 253 252 254;
|
||||||
--text-blue: 8 109 224;
|
--violet-2: 250 248 255;
|
||||||
|
--violet-3: 244 240 254;
|
||||||
|
--violet-4: 235 228 255;
|
||||||
|
--violet-5: 225 217 255;
|
||||||
|
--violet-6: 212 202 254;
|
||||||
|
--violet-7: 194 178 248;
|
||||||
|
--violet-8: 169 153 236;
|
||||||
|
--violet-9: 110 86 207;
|
||||||
|
--violet-10: 100 84 196;
|
||||||
|
--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;
|
||||||
--border-container: 236 236 236;
|
--border-container: 236 236 236;
|
||||||
--border-strong: 235 235 235;
|
--border-strong: 226 227 231;
|
||||||
--border-weak: 234 234 234;
|
--border-weak: 234 234 234;
|
||||||
|
--border-blue-strong: 18 61 117;
|
||||||
--solid-1: 255 255 255;
|
--solid-1: 255 255 255;
|
||||||
--solid-2: 255 255 255;
|
--solid-2: 255 255 255;
|
||||||
--solid-3: 255 255 255;
|
--solid-3: 255 255 255;
|
||||||
--solid-active: 255 255 255;
|
--solid-active: 255 255 255;
|
||||||
--solid-amber: 252 232 193;
|
--solid-amber: 255 228 181;
|
||||||
--solid-blue: 218 236 255;
|
--solid-blue: 218 236 255;
|
||||||
|
--solid-blue-2: 251 253 255;
|
||||||
--solid-iris: 230 231 255;
|
--solid-iris: 230 231 255;
|
||||||
|
--solid-purple: 230 231 255;
|
||||||
|
--solid-red: 254 200 201;
|
||||||
|
--solid-amber-button: 255 221 141;
|
||||||
|
--card-color: 255 255 255;
|
||||||
|
--overlay: 0, 0, 0, 0.12;
|
||||||
|
--overlay-avatar: 255, 255, 255, 0.67;
|
||||||
|
--button-color: 255 255 255;
|
||||||
|
--button-hover-color: 255, 255, 255, 0.2;
|
||||||
|
--label-background: 247 247 247;
|
||||||
|
--label-border: 0, 0, 0, 0.04;
|
||||||
|
|
||||||
--alpha-1: 67, 67, 67, 0.06;
|
--alpha-1: 215, 215, 215, 0.22;
|
||||||
--alpha-2: 201, 202, 207, 0.15;
|
--alpha-2: 196, 197, 198, 0.22;
|
||||||
--alpha-3: 255, 255, 255, 0.96;
|
--alpha-3: 255, 255, 255, 0.96;
|
||||||
--black-alpha-1: 0, 0, 0, 0.12;
|
--black-alpha-1: 0, 0, 0, 0.12;
|
||||||
--black-alpha-2: 0, 0, 0, 0.04;
|
--black-alpha-2: 0, 0, 0, 0.04;
|
||||||
@ -209,25 +240,56 @@
|
|||||||
--gray-11: 180 180 180;
|
--gray-11: 180 180 180;
|
||||||
--gray-12: 238 238 238;
|
--gray-12: 238 238 238;
|
||||||
|
|
||||||
--background-color: 18 18 19;
|
--violet-1: 20 17 31;
|
||||||
--border-strong: 52 52 52;
|
--violet-2: 27 21 37;
|
||||||
--border-weak: 38 38 42;
|
--violet-3: 41 31 67;
|
||||||
|
--violet-4: 50 37 85;
|
||||||
|
--violet-5: 60 46 105;
|
||||||
|
--violet-6: 71 56 135;
|
||||||
|
--violet-7: 86 70 151;
|
||||||
|
--violet-8: 110 86 171;
|
||||||
|
--violet-9: 110 86 207;
|
||||||
|
--violet-10: 125 109 217;
|
||||||
|
--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;
|
||||||
--solid-1: 23 23 26;
|
--solid-1: 23 23 26;
|
||||||
--solid-2: 29 30 36;
|
--solid-2: 29 30 36;
|
||||||
--solid-3: 44 45 54;
|
--solid-3: 44 45 54;
|
||||||
--solid-active: 53 57 66;
|
--solid-active: 53 57 66;
|
||||||
--solid-amber: 42 37 30;
|
--solid-amber: 56 50 41;
|
||||||
--solid-blue: 16 49 91;
|
--solid-blue: 15 57 102;
|
||||||
|
--solid-blue-2: 26 29 35;
|
||||||
--solid-iris: 38 42 101;
|
--solid-iris: 38 42 101;
|
||||||
--text-blue: 126 182 255;
|
--solid-purple: 51 51 107;
|
||||||
|
--solid-red: 90 33 34;
|
||||||
|
--solid-amber-button: 255 221 141;
|
||||||
|
--card-color: 28 30 34;
|
||||||
|
--overlay: 0, 0, 0, 0.4;
|
||||||
|
--overlay-avatar: 0, 0, 0, 0.05;
|
||||||
|
--button-color: 42 43 51;
|
||||||
|
--button-hover-color: 0, 0, 0, 0.15;
|
||||||
|
--label-background: 36 38 45;
|
||||||
|
--label-border: 255, 255, 255, 0.03;
|
||||||
|
|
||||||
--alpha-1: 36, 36, 36, 0.8;
|
--alpha-1: 35, 36, 42, 0.8;
|
||||||
--alpha-2: 139, 147, 182, 0.15;
|
--alpha-2: 147, 153, 176, 0.12;
|
||||||
--alpha-3: 36, 38, 45, 0.9;
|
--alpha-3: 33, 34, 38, 0.95;
|
||||||
--black-alpha-1: 0, 0, 0, 0.3;
|
--black-alpha-1: 0, 0, 0, 0.3;
|
||||||
--black-alpha-2: 0, 0, 0, 0.2;
|
--black-alpha-2: 0, 0, 0, 0.2;
|
||||||
--border-blue: 39, 129, 246, 0.5;
|
--border-blue: 39, 129, 246, 0.5;
|
||||||
--border-container: 236, 236, 236, 0;
|
--border-container: 255, 255, 255, 0;
|
||||||
--white-alpha: 255, 255, 255, 0.1;
|
--white-alpha: 255, 255, 255, 0.1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
@apply bg-n-slate-3;
|
@apply bg-n-slate-3;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
@apply text-n-blue-text;
|
@apply text-n-blue-11;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,6 @@ const policyA = withCount({
|
|||||||
description: 'Distributes conversations evenly among available agents',
|
description: 'Distributes conversations evenly among available agents',
|
||||||
assignmentOrder: 'round_robin',
|
assignmentOrder: 'round_robin',
|
||||||
conversationPriority: 'high',
|
conversationPriority: 'high',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[0], mockInboxes[1]],
|
inboxes: [mockInboxes[0], mockInboxes[1]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@ -50,7 +49,6 @@ const policyB = withCount({
|
|||||||
description: 'Assigns based on capacity and workload',
|
description: 'Assigns based on capacity and workload',
|
||||||
assignmentOrder: 'capacity_based',
|
assignmentOrder: 'capacity_based',
|
||||||
conversationPriority: 'medium',
|
conversationPriority: 'medium',
|
||||||
enabled: true,
|
|
||||||
inboxes: [mockInboxes[2], mockInboxes[3]],
|
inboxes: [mockInboxes[2], mockInboxes[3]],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
@ -61,7 +59,6 @@ const emptyPolicy = withCount({
|
|||||||
description: 'Policy with no assigned inboxes',
|
description: 'Policy with no assigned inboxes',
|
||||||
assignmentOrder: 'manual',
|
assignmentOrder: 'manual',
|
||||||
conversationPriority: 'low',
|
conversationPriority: 'low',
|
||||||
enabled: false,
|
|
||||||
inboxes: [],
|
inboxes: [],
|
||||||
isFetchingInboxes: false,
|
isFetchingInboxes: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,7 +15,6 @@ const props = defineProps({
|
|||||||
assignmentOrder: { type: String, default: '' },
|
assignmentOrder: { type: String, default: '' },
|
||||||
conversationPriority: { type: String, default: '' },
|
conversationPriority: { type: String, default: '' },
|
||||||
assignedInboxCount: { type: Number, default: 0 },
|
assignedInboxCount: { type: Number, default: 0 },
|
||||||
enabled: { type: Boolean, default: false },
|
|
||||||
inboxes: { type: Array, default: () => [] },
|
inboxes: { type: Array, default: () => [] },
|
||||||
isFetchingInboxes: { type: Boolean, default: false },
|
isFetchingInboxes: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
@ -65,22 +64,6 @@ const handleFetchInboxes = () => {
|
|||||||
{{ name }}
|
{{ name }}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<CardPopover
|
||||||
:title="
|
:title="
|
||||||
t(
|
t(
|
||||||
|
|||||||
@ -19,11 +19,15 @@ defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['delete']);
|
const emit = defineEmits(['delete', 'navigate']);
|
||||||
|
|
||||||
const handleDelete = itemId => {
|
const handleDelete = itemId => {
|
||||||
emit('delete', itemId);
|
emit('delete', itemId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNavigate = item => {
|
||||||
|
emit('navigate', item);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -47,7 +51,11 @@ const handleDelete = itemId => {
|
|||||||
:key="item.id"
|
: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"
|
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"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 col-span-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)"
|
||||||
|
>
|
||||||
<Icon
|
<Icon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
@ -61,10 +69,16 @@ const handleDelete = itemId => {
|
|||||||
:size="20"
|
:size="20"
|
||||||
rounded-full
|
rounded-full
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-n-slate-12 truncate min-w-0">
|
<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"
|
||||||
|
>
|
||||||
{{ item.name }}
|
{{ item.name }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<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 class="flex items-start gap-2 col-span-1">
|
<div class="flex items-start gap-2 col-span-1">
|
||||||
<span
|
<span
|
||||||
|
|||||||
@ -119,7 +119,7 @@ onMounted(() => {
|
|||||||
)
|
)
|
||||||
"
|
"
|
||||||
:items="filteredTags"
|
:items="filteredTags"
|
||||||
class="[&>button]:!text-n-blue-text [&>div]:min-w-64"
|
class="[&>button]:!text-n-blue-11 [&>div]:min-w-64"
|
||||||
@add="onClickAddTag"
|
@add="onClickAddTag"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||||
@ -15,6 +15,9 @@ 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', {
|
const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 3600,
|
default: 3600,
|
||||||
@ -25,6 +28,17 @@ const fairDistributionWindow = defineModel('fairDistributionWindow', {
|
|||||||
|
|
||||||
const windowUnit = ref(DURATION_UNITS.MINUTES);
|
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 detectUnit = minutes => {
|
||||||
const m = Number(minutes) || 0;
|
const m = Number(minutes) || 0;
|
||||||
if (m === 0) return DURATION_UNITS.MINUTES;
|
if (m === 0) return DURATION_UNITS.MINUTES;
|
||||||
@ -34,7 +48,7 @@ const detectUnit = minutes => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
windowUnit.value = detectUnit(fairDistributionWindow.value);
|
windowUnit.value = detectUnit(windowInMinutes.value);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -73,9 +87,9 @@ onMounted(() => {
|
|||||||
<div
|
<div
|
||||||
class="flex items-center gap-2 flex-1 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
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 -->
|
<!-- allow 10 mins to 999 days (in minutes) -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model:model-value="fairDistributionWindow"
|
v-model:model-value="windowInMinutes"
|
||||||
v-model:unit="windowUnit"
|
v-model:unit="windowUnit"
|
||||||
:min="10"
|
:min="10"
|
||||||
:max="1438560"
|
:max="1438560"
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
id: {
|
id: {
|
||||||
type: String,
|
type: String,
|
||||||
@ -16,12 +18,22 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabledMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['select']);
|
const emit = defineEmits(['select']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const handleChange = () => {
|
const handleChange = () => {
|
||||||
if (!props.isActive) {
|
if (!props.isActive && !props.disabled) {
|
||||||
emit('select', props.id);
|
emit('select', props.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -29,9 +41,11 @@ const handleChange = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
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="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="[
|
:class="[
|
||||||
isActive ? 'outline-n-blue-9' : 'outline-n-weak hover:outline-n-strong',
|
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||||
|
isActive ? 'outline-n-blue-9' : 'outline-n-weak',
|
||||||
|
!disabled && !isActive ? 'hover:outline-n-strong' : '',
|
||||||
]"
|
]"
|
||||||
@click="handleChange"
|
@click="handleChange"
|
||||||
>
|
>
|
||||||
@ -41,6 +55,7 @@ const handleChange = () => {
|
|||||||
:checked="isActive"
|
:checked="isActive"
|
||||||
:value="id"
|
:value="id"
|
||||||
:name="id"
|
:name="id"
|
||||||
|
:disabled="disabled"
|
||||||
type="radio"
|
type="radio"
|
||||||
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
class="h-4 w-4 border-n-slate-6 text-n-brand focus:ring-n-brand focus:ring-offset-0"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
@ -49,11 +64,23 @@ const handleChange = () => {
|
|||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex flex-col gap-3 items-start">
|
<div class="flex flex-col gap-3 items-start">
|
||||||
<h3 class="text-sm font-medium text-n-slate-12">
|
<div class="flex items-center gap-2">
|
||||||
{{ label }}
|
<h3 class="text-sm font-medium text-n-slate-12">
|
||||||
</h3>
|
{{ 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>
|
||||||
<p class="text-sm text-n-slate-11">
|
<p class="text-sm text-n-slate-11">
|
||||||
{{ description }}
|
{{ disabled && disabledMessage ? disabledMessage : description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,7 +6,6 @@ const policyName = ref('Round Robin Policy');
|
|||||||
const description = ref(
|
const description = ref(
|
||||||
'Distributes conversations evenly among available agents'
|
'Distributes conversations evenly among available agents'
|
||||||
);
|
);
|
||||||
const enabled = ref(true);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -19,13 +18,10 @@ const enabled = ref(true);
|
|||||||
<BaseInfo
|
<BaseInfo
|
||||||
v-model:policy-name="policyName"
|
v-model:policy-name="policyName"
|
||||||
v-model:description="description"
|
v-model:description="description"
|
||||||
v-model:enabled="enabled"
|
|
||||||
name-label="Policy Name"
|
name-label="Policy Name"
|
||||||
name-placeholder="Enter policy name"
|
name-placeholder="Enter policy name"
|
||||||
description-label="Description"
|
description-label="Description"
|
||||||
description-placeholder="Enter policy description"
|
description-placeholder="Enter policy description"
|
||||||
status-label="Status"
|
|
||||||
status-placeholder="Active"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Variant>
|
</Variant>
|
||||||
|
|||||||
@ -20,7 +20,7 @@ const handleButtonClick = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||||
|
|||||||
@ -306,7 +306,7 @@ defineExpose({ prepareCampaignDetails, isSubmitDisabled });
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -174,7 +174,7 @@ const handleSubmit = async () => {
|
|||||||
color="slate"
|
color="slate"
|
||||||
type="button"
|
type="button"
|
||||||
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
:label="t('CAMPAIGN.SMS.CREATE.FORM.BUTTONS.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -251,7 +251,7 @@ watch(
|
|||||||
color="slate"
|
color="slate"
|
||||||
type="button"
|
type="button"
|
||||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -21,7 +21,7 @@ const updateCurrentPage = page => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
<CompanyHeader
|
<CompanyHeader
|
||||||
|
|||||||
@ -73,7 +73,7 @@ const closeMobileSidebar = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
|
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
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"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
download="import-contacts-sample.csv"
|
download="import-contacts-sample.csv"
|
||||||
class="text-n-blue-text"
|
class="text-n-blue-11"
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
|
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.DOWNLOAD_LABEL')
|
||||||
|
|||||||
@ -68,8 +68,7 @@ const hasActiveSegments = computed(
|
|||||||
);
|
);
|
||||||
const activeSegmentName = computed(() => props.activeSegment?.name);
|
const activeSegmentName = computed(() => props.activeSegment?.name);
|
||||||
|
|
||||||
const openCreateNewContactDialog = async () => {
|
const openCreateNewContactDialog = () => {
|
||||||
await createNewContactDialogRef.value?.contactsFormRef.resetValidation();
|
|
||||||
createNewContactDialogRef.value?.dialogRef.open();
|
createNewContactDialogRef.value?.dialogRef.open();
|
||||||
};
|
};
|
||||||
const openContactImportDialog = () =>
|
const openContactImportDialog = () =>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useRoute } from 'vue-router';
|
|||||||
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/ContactsHeader/ContactListHeaderWrapper.vue';
|
||||||
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
|
||||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||||
|
import ContactsLoadMore from 'dashboard/components-next/Contacts/ContactsLoadMore.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
searchValue: { type: String, default: '' },
|
searchValue: { type: String, default: '' },
|
||||||
@ -19,6 +20,9 @@ const props = defineProps({
|
|||||||
segmentsId: { type: [String, Number], default: 0 },
|
segmentsId: { type: [String, Number], default: 0 },
|
||||||
hasAppliedFilters: { type: Boolean, default: false },
|
hasAppliedFilters: { type: Boolean, default: false },
|
||||||
isFetchingList: { 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([
|
const emit = defineEmits([
|
||||||
@ -27,6 +31,7 @@ const emit = defineEmits([
|
|||||||
'search',
|
'search',
|
||||||
'applyFilter',
|
'applyFilter',
|
||||||
'clearFilters',
|
'clearFilters',
|
||||||
|
'loadMore',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -61,11 +66,19 @@ const updateCurrentPage = page => {
|
|||||||
const openFilter = () => {
|
const openFilter = () => {
|
||||||
contactListHeaderWrapper.value?.onToggleFilters();
|
contactListHeaderWrapper.value?.onToggleFilters();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showLoadMore = computed(() => {
|
||||||
|
return props.useInfiniteScroll && props.hasMore;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPagination = computed(() => {
|
||||||
|
return !props.useInfiniteScroll && props.showPaginationFooter;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||||
<ContactListHeaderWrapper
|
<ContactListHeaderWrapper
|
||||||
@ -94,9 +107,14 @@ const openFilter = () => {
|
|||||||
@open-filter="openFilter"
|
@open-filter="openFilter"
|
||||||
/>
|
/>
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
|
<ContactsLoadMore
|
||||||
|
v-if="showLoadMore"
|
||||||
|
:is-loading="isLoadingMore"
|
||||||
|
@load-more="emit('loadMore')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
<footer v-if="showPagination" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
|
|||||||
@ -0,0 +1,28 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['loadMore']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.LOAD_MORE')"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
variant="faded"
|
||||||
|
color="slate"
|
||||||
|
size="sm"
|
||||||
|
@click="emit('loadMore')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -130,7 +130,7 @@ const onMergeContacts = async () => {
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="resetState"
|
@click="resetState"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,248 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
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':
|
'cursor-pointer text-n-slate-11 hover:text-n-slate-12 py-2 select-none font-medium':
|
||||||
!isEditingView,
|
!isEditingView,
|
||||||
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
|
'text-n-slate-12 truncate': isEditingView && !isAttributeTypeLink,
|
||||||
'truncate hover:text-n-brand text-n-blue-text':
|
'truncate hover:text-n-brand text-n-blue-11':
|
||||||
isEditingView && isAttributeTypeLink,
|
isEditingView && isAttributeTypeLink,
|
||||||
}"
|
}"
|
||||||
@click="toggleEditValue(!isEditingView)"
|
@click="toggleEditValue(!isEditingView)"
|
||||||
|
|||||||
@ -37,7 +37,7 @@ defineProps({
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||||
:class="{
|
:class="{
|
||||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-surface-1 from-25% to-transparent':
|
||||||
showBackdrop,
|
showBackdrop,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
@ -48,14 +48,12 @@ defineProps({
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center justify-center gap-3">
|
<div class="flex flex-col items-center justify-center gap-3">
|
||||||
<h2
|
<h2 class="text-3xl font-medium text-center text-n-slate-12">
|
||||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
|
||||||
>
|
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</h2>
|
||||||
<p
|
<p
|
||||||
v-if="subtitle"
|
v-if="subtitle"
|
||||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
class="max-w-xl text-base text-center text-n-slate-11 tracking-[0.3px]"
|
||||||
>
|
>
|
||||||
{{ subtitle }}
|
{{ subtitle }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -126,7 +126,7 @@ const handleClick = id => {
|
|||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
<span
|
<span
|
||||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12 line-clamp-1"
|
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||||
@click="handleClick(id)"
|
@click="handleClick(id)"
|
||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
|
|||||||
@ -83,7 +83,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
<div class="flex justify-between w-full gap-2">
|
<div class="flex justify-between w-full gap-2">
|
||||||
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
<div class="flex items-center justify-start w-full min-w-0 gap-2">
|
||||||
<span
|
<span
|
||||||
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-text text-n-slate-12"
|
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
|
||||||
@click="handleClick(slug)"
|
@click="handleClick(slug)"
|
||||||
>
|
>
|
||||||
{{ categoryTitleWithIcon }}
|
{{ categoryTitleWithIcon }}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ const togglePortalSwitcher = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||||
<header class="sticky top-0 z-10 px-6 pb-3 lg:px-0">
|
<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 class="w-full max-w-[60rem] mx-auto lg:px-6">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -60,7 +60,7 @@ const handleAction = ({ action, value }) => {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isDefault"
|
v-if="isDefault"
|
||||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-text px-2 py-0.5"
|
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-blue-11 px-2 py-0.5"
|
||||||
>
|
>
|
||||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -246,7 +246,7 @@ defineExpose({ state, isSubmitDisabled });
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.BUTTONS.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -100,7 +100,7 @@ const formattedMessage = computed(() => {
|
|||||||
|
|
||||||
const notificationDetails = computed(() => {
|
const notificationDetails = computed(() => {
|
||||||
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
|
const type = props.inboxItem?.notificationType?.toUpperCase() || '';
|
||||||
const [icon = '', color = 'text-n-blue-text'] =
|
const [icon = '', color = 'text-n-blue-11'] =
|
||||||
NOTIFICATION_TYPES_MAPPING[type] || [];
|
NOTIFICATION_TYPES_MAPPING[type] || [];
|
||||||
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
|
return { text: type ? t(`INBOX.TYPES_NEXT.${type}`) : '', icon, color };
|
||||||
});
|
});
|
||||||
@ -181,11 +181,11 @@ onBeforeMount(contextMenuActions.close);
|
|||||||
: 'i-lucide-alarm-clock-off'
|
: 'i-lucide-alarm-clock-off'
|
||||||
"
|
"
|
||||||
class="flex-shrink-0 size-4"
|
class="flex-shrink-0 size-4"
|
||||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-xs font-medium truncate"
|
class="text-xs font-medium truncate"
|
||||||
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-text'"
|
:class="!isUnread ? 'text-n-slate-11' : 'text-n-blue-11'"
|
||||||
>
|
>
|
||||||
{{ snoozedText }}
|
{{ snoozedText }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useWindowSize } from '@vueuse/core';
|
import { useWindowSize } from '@vueuse/core';
|
||||||
@ -59,6 +59,23 @@ const isFetchingInboxes = ref(false);
|
|||||||
const isSearching = ref(false);
|
const isSearching = ref(false);
|
||||||
const showComposeNewConversation = 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 contactById = useMapGetter('contacts/getContactById');
|
||||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
@ -140,12 +157,14 @@ const handleSelectedContact = async ({ value, action, ...rest }) => {
|
|||||||
|
|
||||||
const handleTargetInbox = inbox => {
|
const handleTargetInbox = inbox => {
|
||||||
targetInbox.value = inbox;
|
targetInbox.value = inbox;
|
||||||
|
if (!inbox) clearFormState();
|
||||||
resetContacts();
|
resetContacts();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
selectedContact.value = null;
|
selectedContact.value = null;
|
||||||
targetInbox.value = null;
|
targetInbox.value = null;
|
||||||
|
clearFormState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCompose = () => {
|
const closeCompose = () => {
|
||||||
@ -160,6 +179,12 @@ const closeCompose = () => {
|
|||||||
emit('close');
|
emit('close');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const discardCompose = () => {
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
closeCompose();
|
||||||
|
};
|
||||||
|
|
||||||
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||||
try {
|
try {
|
||||||
const data = await store.dispatch('contactConversations/create', {
|
const data = await store.dispatch('contactConversations/create', {
|
||||||
@ -171,7 +196,7 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
|||||||
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
|
||||||
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
|
||||||
};
|
};
|
||||||
closeCompose();
|
discardCompose();
|
||||||
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
|
||||||
return true; // Return success
|
return true; // Return success
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -193,7 +218,11 @@ watch(
|
|||||||
(currentContact, previousContact) => {
|
(currentContact, previousContact) => {
|
||||||
if (currentContact && props.contactId) {
|
if (currentContact && props.contactId) {
|
||||||
// Reset on contact change
|
// Reset on contact change
|
||||||
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
|
if (currentContact?.id !== previousContact?.id) {
|
||||||
|
clearSelectedContact();
|
||||||
|
clearFormState();
|
||||||
|
formState.message = '';
|
||||||
|
}
|
||||||
|
|
||||||
// First process the contactable inboxes to get the right structure
|
// First process the contactable inboxes to get the right structure
|
||||||
const processedInboxes = processContactableInboxes(
|
const processedInboxes = processContactableInboxes(
|
||||||
@ -265,6 +294,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@click.self="onModalBackdropClick"
|
@click.self="onModalBackdropClick"
|
||||||
>
|
>
|
||||||
<ComposeNewConversationForm
|
<ComposeNewConversationForm
|
||||||
|
:form-state="formState"
|
||||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:contact-id="contactId"
|
:contact-id="contactId"
|
||||||
@ -285,7 +315,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
@update-target-inbox="handleTargetInbox"
|
@update-target-inbox="handleTargetInbox"
|
||||||
@clear-selected-contact="clearSelectedContact"
|
@clear-selected-contact="clearSelectedContact"
|
||||||
@create-conversation="createConversation"
|
@create-conversation="createConversation"
|
||||||
@discard="closeCompose"
|
@discard="discardCompose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, requiredIf } from '@vuelidate/validators';
|
import { required, requiredIf } from '@vuelidate/validators';
|
||||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
@ -35,6 +35,7 @@ const props = defineProps({
|
|||||||
contactsUiFlags: { type: Object, default: null },
|
contactsUiFlags: { type: Object, default: null },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
formState: { type: Object, required: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@ -55,13 +56,13 @@ const showBccEmailsDropdown = ref(false);
|
|||||||
|
|
||||||
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
|
||||||
|
|
||||||
const state = reactive({
|
const state = props.formState || {
|
||||||
message: '',
|
message: '',
|
||||||
subject: '',
|
subject: '',
|
||||||
ccEmails: '',
|
ccEmails: '',
|
||||||
bccEmails: '',
|
bccEmails: '',
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
});
|
};
|
||||||
|
|
||||||
const inboxTypes = computed(() => ({
|
const inboxTypes = computed(() => ({
|
||||||
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
|
||||||
|
|||||||
@ -95,6 +95,7 @@ const inputClass = computed(() => {
|
|||||||
:show-dropdown="showCcEmailsDropdown"
|
:show-dropdown="showCcEmailsDropdown"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
type="email"
|
type="email"
|
||||||
|
allow-create
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
@focus="emit('updateDropdown', 'cc', true)"
|
@focus="emit('updateDropdown', 'cc', true)"
|
||||||
@input="emit('searchCcEmails', $event)"
|
@input="emit('searchCcEmails', $event)"
|
||||||
@ -127,6 +128,7 @@ const inputClass = computed(() => {
|
|||||||
:show-dropdown="showBccEmailsDropdown"
|
:show-dropdown="showBccEmailsDropdown"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
type="email"
|
type="email"
|
||||||
|
allow-create
|
||||||
class="flex-1 min-h-7"
|
class="flex-1 min-h-7"
|
||||||
focus-on-mount
|
focus-on-mount
|
||||||
@focus="emit('updateDropdown', 'bcc', true)"
|
@focus="emit('updateDropdown', 'bcc', true)"
|
||||||
|
|||||||
@ -103,11 +103,11 @@ const STYLE_CONFIG = {
|
|||||||
solid:
|
solid:
|
||||||
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
|
'bg-n-brand text-white hover:enabled:brightness-110 focus-visible:brightness-110 outline-transparent',
|
||||||
faded:
|
faded:
|
||||||
'bg-n-brand/10 text-n-blue-text hover:enabled:bg-n-brand/20 focus-visible:bg-n-brand/20 outline-transparent',
|
'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-text outline-n-brand',
|
outline: 'text-n-blue-11 outline-n-brand',
|
||||||
ghost:
|
ghost:
|
||||||
'text-n-blue-text hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 outline-transparent',
|
'text-n-blue-11 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',
|
link: 'text-n-blue-11 hover:enabled:underline focus-visible:underline outline-transparent',
|
||||||
},
|
},
|
||||||
ruby: {
|
ruby: {
|
||||||
solid:
|
solid:
|
||||||
@ -133,7 +133,7 @@ const STYLE_CONFIG = {
|
|||||||
},
|
},
|
||||||
slate: {
|
slate: {
|
||||||
solid:
|
solid:
|
||||||
'bg-n-solid-3 dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
'bg-n-button-color dark:hover:enabled:bg-n-solid-2 dark:focus-visible:bg-n-solid-2 hover:enabled:bg-n-alpha-2 focus-visible:bg-n-alpha-2 text-n-slate-12 outline-n-container',
|
||||||
faded:
|
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',
|
'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:
|
outline:
|
||||||
|
|||||||
@ -115,7 +115,7 @@ const handleCreateAssistant = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||||
<header class="sticky top-0 z-10 px-6">
|
<header class="sticky top-0 z-10 px-6">
|
||||||
<div class="w-full max-w-[60rem] mx-auto">
|
<div class="w-full max-w-[60rem] mx-auto">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -141,7 +141,7 @@ const onClickCancel = () => {
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
|
:label="t('CAPTAIN.ASSISTANTS.SCENARIOS.ADD.NEW.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 !text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 !text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="onClickCancel"
|
@click="onClickCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -169,7 +169,7 @@ watch(
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -254,7 +254,7 @@ const handleSubmit = async () => {
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -220,7 +220,7 @@ const handleSubmit = async () => {
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -18,7 +18,9 @@ const dialogRef = ref(null);
|
|||||||
|
|
||||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||||
const responses = useMapGetter('captainResponses/getRecords');
|
const responses = useMapGetter('captainResponses/getRecords');
|
||||||
|
const meta = useMapGetter('captainResponses/getMeta');
|
||||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||||
|
const totalCount = computed(() => meta.value.totalCount || 0);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
emit('close');
|
emit('close');
|
||||||
@ -37,7 +39,7 @@ defineExpose({ dialogRef });
|
|||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="edit"
|
type="edit"
|
||||||
:title="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')"
|
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
|
||||||
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
:show-confirm-button="false"
|
:show-confirm-button="false"
|
||||||
|
|||||||
@ -100,7 +100,7 @@ const handleSubmit = async () => {
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -116,7 +116,7 @@ watch(
|
|||||||
variant="faded"
|
variant="faded"
|
||||||
color="slate"
|
color="slate"
|
||||||
:label="t('CAPTAIN.FORM.CANCEL')"
|
:label="t('CAPTAIN.FORM.CANCEL')"
|
||||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -34,7 +34,7 @@ const handleImgClick = () => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
data-testid="changelog-card"
|
data-testid="changelog-card"
|
||||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
|
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-card text-n-slate-12"
|
||||||
:class="{
|
:class="{
|
||||||
'animate-fade-out pointer-events-none': isDismissing,
|
'animate-fade-out pointer-events-none': isDismissing,
|
||||||
'hover:shadow': isActive,
|
'hover:shadow': isActive,
|
||||||
|
|||||||
@ -56,8 +56,13 @@ const selectedLabel = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectOption = option => {
|
const selectOption = option => {
|
||||||
selectedValue.value = option.value;
|
if (selectedValue.value === option.value) {
|
||||||
emit('update:modelValue', option.value);
|
selectedValue.value = '';
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
} else {
|
||||||
|
selectedValue.value = option.value;
|
||||||
|
emit('update:modelValue', option.value);
|
||||||
|
}
|
||||||
open.value = false;
|
open.value = false;
|
||||||
search.value = '';
|
search.value = '';
|
||||||
};
|
};
|
||||||
|
|||||||
@ -53,6 +53,11 @@ const props = defineProps({
|
|||||||
default: 'lg',
|
default: 'lg',
|
||||||
validator: value => ['3xl', '2xl', 'xl', 'lg', 'md', 'sm'].includes(value),
|
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']);
|
const emit = defineEmits(['confirm', 'close']);
|
||||||
@ -61,6 +66,7 @@ const { t } = useI18n();
|
|||||||
|
|
||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const dialogContentRef = ref(null);
|
const dialogContentRef = ref(null);
|
||||||
|
const isOpen = ref(false);
|
||||||
|
|
||||||
const maxWidthClass = computed(() => {
|
const maxWidthClass = computed(() => {
|
||||||
const classesMap = {
|
const classesMap = {
|
||||||
@ -75,13 +81,19 @@ const maxWidthClass = computed(() => {
|
|||||||
return classesMap[props.width] ?? 'max-w-md';
|
return classesMap[props.width] ?? 'max-w-md';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const positionClass = computed(() =>
|
||||||
|
props.position === 'top' ? 'dialog-position-top' : ''
|
||||||
|
);
|
||||||
|
|
||||||
const open = () => {
|
const open = () => {
|
||||||
|
isOpen.value = true;
|
||||||
dialogRef.value?.showModal();
|
dialogRef.value?.showModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
emit('close');
|
emit('close');
|
||||||
dialogRef.value?.close();
|
dialogRef.value?.close();
|
||||||
|
isOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirm = () => {
|
const confirm = () => {
|
||||||
@ -98,6 +110,7 @@ defineExpose({ open, close });
|
|||||||
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
class="w-full transition-all duration-300 ease-in-out shadow-xl rounded-xl"
|
||||||
:class="[
|
:class="[
|
||||||
maxWidthClass,
|
maxWidthClass,
|
||||||
|
positionClass,
|
||||||
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
overflowYAuto ? 'overflow-y-auto' : 'overflow-visible',
|
||||||
]"
|
]"
|
||||||
@close="close"
|
@close="close"
|
||||||
@ -105,7 +118,7 @@ defineExpose({ open, close });
|
|||||||
<OnClickOutside @trigger="close">
|
<OnClickOutside @trigger="close">
|
||||||
<form
|
<form
|
||||||
ref="dialogContentRef"
|
ref="dialogContentRef"
|
||||||
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"
|
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"
|
||||||
@submit.prevent="confirm"
|
@submit.prevent="confirm"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
@ -119,7 +132,7 @@ defineExpose({ open, close });
|
|||||||
</p>
|
</p>
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot v-if="isOpen" />
|
||||||
<!-- Dialog content will be injected here -->
|
<!-- Dialog content will be injected here -->
|
||||||
<slot name="footer">
|
<slot name="footer">
|
||||||
<div
|
<div
|
||||||
@ -156,4 +169,9 @@ defineExpose({ open, close });
|
|||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
@apply bg-n-alpha-black1 backdrop-blur-[4px];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-position-top {
|
||||||
|
margin-top: clamp(2rem, 5vh, 5rem);
|
||||||
|
margin-bottom: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -4,6 +4,10 @@ defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
height: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-96',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -15,7 +19,10 @@ defineProps({
|
|||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
<ul class="gap-2 grid reset-base list-none px-2 max-h-96 overflow-y-auto">
|
<ul
|
||||||
|
class="gap-2 grid reset-base list-none px-2 overflow-y-auto"
|
||||||
|
:class="height"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -50,12 +50,12 @@ const currentFilter = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getOperator = (filter, selectedOperator) => {
|
const getOperator = (filter, selectedOperator) => {
|
||||||
const operatorFromOptions = filter.filterOperators.find(
|
const operatorFromOptions = filter?.filterOperators?.find(
|
||||||
operator => operator.value === selectedOperator
|
operator => operator.value === selectedOperator
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!operatorFromOptions) {
|
if (!operatorFromOptions) {
|
||||||
return filter.filterOperators[0];
|
return filter?.filterOperators?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
return operatorFromOptions;
|
return operatorFromOptions;
|
||||||
@ -77,12 +77,12 @@ const queryOperatorOptions = computed(() => {
|
|||||||
{
|
{
|
||||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
|
label: t(`FILTER.QUERY_DROPDOWN_LABELS.AND`),
|
||||||
value: 'and',
|
value: 'and',
|
||||||
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-text' }),
|
icon: h('span', { class: 'i-lucide-ampersands !text-n-blue-11' }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
|
label: t(`FILTER.QUERY_DROPDOWN_LABELS.OR`),
|
||||||
value: 'or',
|
value: 'or',
|
||||||
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-text' }),
|
icon: h('span', { class: 'i-woot-logic-or !text-n-blue-11' }),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@ -138,7 +138,11 @@ const validate = () => {
|
|||||||
return !validationError.value;
|
return !validationError.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({ validate });
|
const resetValidation = () => {
|
||||||
|
showErrors.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ validate, resetValidation });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -166,18 +170,20 @@ defineExpose({ validate });
|
|||||||
<FilterSelect
|
<FilterSelect
|
||||||
v-model="filterOperator"
|
v-model="filterOperator"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
:options="currentFilter.filterOperators"
|
:options="currentFilter?.filterOperators"
|
||||||
/>
|
/>
|
||||||
<template v-if="currentOperator.hasInput">
|
<template v-if="currentOperator?.hasInput">
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
v-if="inputType === 'multiSelect'"
|
v-if="inputType === 'multiSelect'"
|
||||||
v-model="values"
|
v-model="values"
|
||||||
:options="currentFilter.options"
|
:options="currentFilter.options"
|
||||||
|
dropdown-max-height="max-h-72"
|
||||||
/>
|
/>
|
||||||
<SingleSelect
|
<SingleSelect
|
||||||
v-else-if="inputType === 'searchSelect'"
|
v-else-if="inputType === 'searchSelect'"
|
||||||
v-model="values"
|
v-model="values"
|
||||||
:options="currentFilter.options"
|
:options="currentFilter.options"
|
||||||
|
dropdown-max-height="max-h-64"
|
||||||
/>
|
/>
|
||||||
<SingleSelect
|
<SingleSelect
|
||||||
v-else-if="inputType === 'booleanSelect'"
|
v-else-if="inputType === 'booleanSelect'"
|
||||||
|
|||||||
@ -45,7 +45,7 @@ const { height } = useWindowSize();
|
|||||||
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
const { height: dropdownHeight } = useElementBounding(dropdownRef);
|
||||||
|
|
||||||
const selectedOption = computed(() => {
|
const selectedOption = computed(() => {
|
||||||
return props.options.find(o => o.value === selected.value) || {};
|
return props.options?.find(o => o.value === selected.value) || {};
|
||||||
});
|
});
|
||||||
|
|
||||||
const iconToRender = computed(() => {
|
const iconToRender = computed(() => {
|
||||||
@ -87,18 +87,25 @@ const updateSelected = newValue => {
|
|||||||
</template>
|
</template>
|
||||||
<DropdownBody
|
<DropdownBody
|
||||||
ref="dropdownRef"
|
ref="dropdownRef"
|
||||||
class="min-w-48 z-50"
|
class="min-w-56 z-50"
|
||||||
:class="dropdownPosition"
|
:class="dropdownPosition"
|
||||||
strong
|
strong
|
||||||
>
|
>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection class="[&>ul]:max-h-72">
|
||||||
<DropdownItem
|
<template v-for="option in options" :key="option.value">
|
||||||
v-for="option in options"
|
<li
|
||||||
:key="option.value"
|
v-if="option.disabled"
|
||||||
:label="option.label"
|
class="px-2 py-1.5 text-xs font-medium text-n-slate-10 select-none"
|
||||||
:icon="option.icon"
|
>
|
||||||
@click="updateSelected(option.value)"
|
{{ option.label }}
|
||||||
/>
|
</li>
|
||||||
|
<DropdownItem
|
||||||
|
v-else
|
||||||
|
:label="option.label"
|
||||||
|
:icon="option.icon"
|
||||||
|
@click="updateSelected(option.value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</DropdownSection>
|
</DropdownSection>
|
||||||
</DropdownBody>
|
</DropdownBody>
|
||||||
</DropdownContainer>
|
</DropdownContainer>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
|
|||||||
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
|
||||||
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
||||||
|
|
||||||
const { options, maxChips } = defineProps({
|
const { options, maxChips, dropdownMaxHeight } = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
@ -17,6 +17,10 @@ const { options, maxChips } = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 3,
|
default: 3,
|
||||||
},
|
},
|
||||||
|
dropdownMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-80',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -123,7 +127,7 @@ const toggleOption = option => {
|
|||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
<DropdownBody class="top-0 min-w-48 z-50" strong>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection :height="dropdownMaxHeight">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in options"
|
v-for="option in options"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
|
|||||||
@ -12,10 +12,12 @@ import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
|
|||||||
const {
|
const {
|
||||||
options,
|
options,
|
||||||
disableSearch,
|
disableSearch,
|
||||||
|
disableDeselect,
|
||||||
placeholderIcon,
|
placeholderIcon,
|
||||||
placeholder,
|
placeholder,
|
||||||
placeholderTrailingIcon,
|
placeholderTrailingIcon,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
|
dropdownMaxHeight,
|
||||||
} = defineProps({
|
} = defineProps({
|
||||||
options: {
|
options: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@ -41,6 +43,14 @@ const {
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
dropdownMaxHeight: {
|
||||||
|
type: String,
|
||||||
|
default: 'max-h-80',
|
||||||
|
},
|
||||||
|
disableDeselect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -63,6 +73,8 @@ const selectedItem = computed(() => {
|
|||||||
const optionToSearch = Array.isArray(selected.value)
|
const optionToSearch = Array.isArray(selected.value)
|
||||||
? selected.value[0]
|
? selected.value[0]
|
||||||
: selected.value;
|
: selected.value;
|
||||||
|
|
||||||
|
if (!optionToSearch) return null;
|
||||||
// extract the selected item from the options array
|
// extract the selected item from the options array
|
||||||
// this ensures that options like icon is also included
|
// this ensures that options like icon is also included
|
||||||
return options.find(option => option.id === optionToSearch.id);
|
return options.find(option => option.id === optionToSearch.id);
|
||||||
@ -77,7 +89,7 @@ const toggleSelected = option => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (selected.value && selected.value.id === optionToToggle.id) {
|
if (selected.value && selected.value.id === optionToToggle.id) {
|
||||||
selected.value = null;
|
if (!disableDeselect) selected.value = null;
|
||||||
} else {
|
} else {
|
||||||
selected.value = optionToToggle;
|
selected.value = optionToToggle;
|
||||||
}
|
}
|
||||||
@ -124,7 +136,7 @@ const toggleSelected = option => {
|
|||||||
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<DropdownSection class="[&>ul]:max-h-80">
|
<DropdownSection :height="dropdownMaxHeight">
|
||||||
<template v-if="searchResults.length">
|
<template v-if="searchResults.length">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="option in searchResults"
|
v-for="option in searchResults"
|
||||||
|
|||||||
@ -82,7 +82,7 @@ export function useOperators() {
|
|||||||
hasInput: !NO_INPUT_OPTS.includes(value),
|
hasInput: !NO_INPUT_OPTS.includes(value),
|
||||||
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
|
inputOverride: OPS_INPUT_OVERRIDE[value] || null,
|
||||||
icon: h('span', {
|
icon: h('span', {
|
||||||
class: `${filterOperatorIcon[value]} !text-n-blue-text`,
|
class: `${filterOperatorIcon[value]} !text-n-blue-11`,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
|
|||||||
@ -0,0 +1,45 @@
|
|||||||
|
<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,12 +3,14 @@ import { onMounted, computed, ref, toRefs } from 'vue';
|
|||||||
import { useTimeoutFn } from '@vueuse/core';
|
import { useTimeoutFn } from '@vueuse/core';
|
||||||
import { provideMessageContext } from './provider.js';
|
import { provideMessageContext } from './provider.js';
|
||||||
import { useTrack } from 'dashboard/composables';
|
import { useTrack } from 'dashboard/composables';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
MESSAGE_TYPES,
|
MESSAGE_TYPES,
|
||||||
@ -41,6 +43,7 @@ import VoiceCallBubble from './bubbles/VoiceCall.vue';
|
|||||||
|
|
||||||
import MessageError from './MessageError.vue';
|
import MessageError from './MessageError.vue';
|
||||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||||
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} Attachment
|
* @typedef {Object} Attachment
|
||||||
@ -143,6 +146,9 @@ const showBackgroundHighlight = ref(false);
|
|||||||
const showContextMenu = ref(false);
|
const showContextMenu = ref(false);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
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
|
* Computes the message variant based on props
|
||||||
@ -166,6 +172,10 @@ const variant = computed(() => {
|
|||||||
if (props.contentAttributes?.isUnsupported)
|
if (props.contentAttributes?.isUnsupported)
|
||||||
return MESSAGE_VARIANTS.UNSUPPORTED;
|
return MESSAGE_VARIANTS.UNSUPPORTED;
|
||||||
|
|
||||||
|
if (props.contentAttributes?.externalEcho) {
|
||||||
|
return MESSAGE_VARIANTS.AGENT;
|
||||||
|
}
|
||||||
|
|
||||||
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
const isBot = !props.sender || props.sender.type === SENDER_TYPES.AGENT_BOT;
|
||||||
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
if (isBot && props.messageType === MESSAGE_TYPES.OUTGOING) {
|
||||||
return MESSAGE_VARIANTS.BOT;
|
return MESSAGE_VARIANTS.BOT;
|
||||||
@ -307,6 +317,7 @@ const componentToRender = computed(() => {
|
|||||||
const instagramSharedTypes = [
|
const instagramSharedTypes = [
|
||||||
ATTACHMENT_TYPES.STORY_MENTION,
|
ATTACHMENT_TYPES.STORY_MENTION,
|
||||||
ATTACHMENT_TYPES.IG_STORY,
|
ATTACHMENT_TYPES.IG_STORY,
|
||||||
|
ATTACHMENT_TYPES.IG_STORY_REPLY,
|
||||||
ATTACHMENT_TYPES.IG_POST,
|
ATTACHMENT_TYPES.IG_POST,
|
||||||
];
|
];
|
||||||
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
if (instagramSharedTypes.includes(props.contentAttributes.imageType)) {
|
||||||
@ -390,13 +401,17 @@ const shouldRenderMessage = computed(() => {
|
|||||||
const isUnsupported = props.contentAttributes?.isUnsupported;
|
const isUnsupported = props.contentAttributes?.isUnsupported;
|
||||||
const isAnIntegrationMessage =
|
const isAnIntegrationMessage =
|
||||||
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
props.contentType === CONTENT_TYPES.INTEGRATIONS;
|
||||||
|
const isFailedMessage = props.status === MESSAGE_STATUS.FAILED;
|
||||||
|
const hasExternalError = !!props.contentAttributes?.externalError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
hasAttachments ||
|
hasAttachments ||
|
||||||
props.content ||
|
props.content ||
|
||||||
isEmailContentType ||
|
isEmailContentType ||
|
||||||
isUnsupported ||
|
isUnsupported ||
|
||||||
isAnIntegrationMessage
|
isAnIntegrationMessage ||
|
||||||
|
isFailedMessage ||
|
||||||
|
hasExternalError
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -433,6 +448,18 @@ function handleReplyTo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avatarInfo = computed(() => {
|
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, check for external sender name
|
// If no sender, check for external sender name
|
||||||
if (!props.sender) {
|
if (!props.sender) {
|
||||||
const externalSenderName = props.contentAttributes?.externalSenderName;
|
const externalSenderName = props.contentAttributes?.externalSenderName;
|
||||||
@ -468,6 +495,9 @@ const avatarInfo = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const avatarTooltip = computed(() => {
|
const avatarTooltip = computed(() => {
|
||||||
|
if (props.contentAttributes?.externalEcho) {
|
||||||
|
return replaceInstallationName(t('CONVERSATION.NATIVE_APP_ADVISORY'));
|
||||||
|
}
|
||||||
if (avatarInfo.value.name === '') return '';
|
if (avatarInfo.value.name === '') return '';
|
||||||
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
return `${t('CONVERSATION.SENT_BY')} ${avatarInfo.value.name}`;
|
||||||
});
|
});
|
||||||
@ -501,7 +531,7 @@ provideMessageContext({
|
|||||||
<div
|
<div
|
||||||
v-if="shouldRenderMessage"
|
v-if="shouldRenderMessage"
|
||||||
:id="`message${props.id}`"
|
:id="`message${props.id}`"
|
||||||
class="flex mb-2 w-full message-bubble-container"
|
class="flex w-full mb-2 message-bubble-container"
|
||||||
:data-message-id="props.id"
|
:data-message-id="props.id"
|
||||||
:class="[
|
:class="[
|
||||||
flexOrientationClass,
|
flexOrientationClass,
|
||||||
|
|||||||
@ -12,11 +12,16 @@ defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['retry']);
|
const emit = defineEmits(['retry']);
|
||||||
|
|
||||||
const { orientation, status, createdAt } = useMessageContext();
|
const { orientation, status, createdAt, content, attachments } =
|
||||||
|
useMessageContext();
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const canRetry = computed(() => !hasOneDayPassed(createdAt.value));
|
const canRetry = computed(() => {
|
||||||
|
const hasContent = content.value !== null;
|
||||||
|
const hasAttachments = attachments.value && attachments.value.length > 0;
|
||||||
|
return !hasOneDayPassed(createdAt.value) && (hasContent || hasAttachments);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -168,7 +168,7 @@ const getInReplyToMessage = parentMessage => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ul class="px-4 bg-n-background">
|
<ul class="px-4 bg-n-surface-1">
|
||||||
<slot name="beforeAll" />
|
<slot name="beforeAll" />
|
||||||
<template v-for="(message, index) in allMessages" :key="message.id">
|
<template v-for="(message, index) in allMessages" :key="message.id">
|
||||||
<slot
|
<slot
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { emitter } from 'shared/helpers/mitt';
|
|||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
import { MESSAGE_VARIANTS, ORIENTATION } from '../constants';
|
||||||
|
|
||||||
@ -99,7 +100,7 @@ const replyToPreview = computed(() => {
|
|||||||
|
|
||||||
const { content, attachments } = inReplyTo.value;
|
const { content, attachments } = inReplyTo.value;
|
||||||
|
|
||||||
if (content) return content;
|
if (content) return new MessageFormatter(content).formattedMessage;
|
||||||
if (attachments?.length) {
|
if (attachments?.length) {
|
||||||
const firstAttachment = attachments[0];
|
const firstAttachment = attachments[0];
|
||||||
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
const fileType = firstAttachment.fileType ?? firstAttachment.file_type;
|
||||||
@ -126,9 +127,10 @@ const replyToPreview = computed(() => {
|
|||||||
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer bg-n-alpha-black1"
|
||||||
@click="scrollToMessage"
|
@click="scrollToMessage"
|
||||||
>
|
>
|
||||||
<span class="break-all line-clamp-2">
|
<div
|
||||||
{{ replyToPreview }}
|
v-dompurify-html="replyToPreview"
|
||||||
</span>
|
class="prose prose-bubble line-clamp-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
<MessageMeta
|
<MessageMeta
|
||||||
|
|||||||
@ -1,19 +1,26 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useMessageContext } from '../provider.js';
|
import { useMessageContext } from '../provider.js';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||||
|
|
||||||
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
|
||||||
import { MESSAGE_VARIANTS } from '../constants';
|
import { MESSAGE_VARIANTS, ATTACHMENT_TYPES } from '../constants';
|
||||||
|
|
||||||
const emit = defineEmits(['error']);
|
const emit = defineEmits(['error']);
|
||||||
const { variant, content, attachments } = useMessageContext();
|
const { t } = useI18n();
|
||||||
|
const { variant, content, contentAttributes, attachments } =
|
||||||
|
useMessageContext();
|
||||||
|
|
||||||
const attachment = computed(() => {
|
const attachment = computed(() => {
|
||||||
return attachments.value[0];
|
return attachments.value[0];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isStoryReply = computed(() => {
|
||||||
|
return contentAttributes.value?.imageType === ATTACHMENT_TYPES.IG_STORY_REPLY;
|
||||||
|
});
|
||||||
|
|
||||||
const hasImgStoryError = ref(false);
|
const hasImgStoryError = ref(false);
|
||||||
const hasVideoStoryError = ref(false);
|
const hasVideoStoryError = ref(false);
|
||||||
|
|
||||||
@ -38,6 +45,9 @@ const onVideoLoadError = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="p-3 overflow-hidden" data-bubble-name="ig-story">
|
<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" />
|
<div v-if="content" v-dompurify-html="formattedContent" class="mb-2" />
|
||||||
<img
|
<img
|
||||||
v-if="!hasImgStoryError"
|
v-if="!hasImgStoryError"
|
||||||
|
|||||||
@ -22,7 +22,7 @@ defineProps({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button :label="buttonText" slate class="!text-n-blue-text w-full" />
|
<Button :label="buttonText" slate class="!text-n-blue-11 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -18,12 +18,8 @@ defineProps({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
|
||||||
<Button
|
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
|
||||||
label="Visit our website"
|
|
||||||
slate
|
|
||||||
class="!text-n-blue-text w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,9 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMessageContext } from '../provider.js';
|
||||||
|
import { useInbox } from 'dashboard/composables/useInbox';
|
||||||
import BaseBubble from './Base.vue';
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
<BaseBubble class="px-4 py-3 text-sm" data-bubble-name="unsupported">
|
||||||
{{ $t('CONVERSATION.UNSUPPORTED_MESSAGE') }}
|
{{ $t(unsupportedMessageKey) }}
|
||||||
</BaseBubble>
|
</BaseBubble>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export const ATTACHMENT_TYPES = {
|
|||||||
EMBED: 'embed',
|
EMBED: 'embed',
|
||||||
IG_POST: 'ig_post',
|
IG_POST: 'ig_post',
|
||||||
IG_STORY: 'ig_story',
|
IG_STORY: 'ig_story',
|
||||||
|
IG_STORY_REPLY: 'ig_story_reply',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CONTENT_TYPES = {
|
export const CONTENT_TYPES = {
|
||||||
|
|||||||
@ -71,7 +71,7 @@ const pageInfo = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-background before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
class="flex justify-between h-12 w-full max-w-[calc(60rem-3px)] outline outline-n-container outline-1 -outline-offset-1 mx-auto bg-n-solid-2 rounded-xl py-2 ltr:pl-4 rtl:pr-4 ltr:pr-3 rtl:pl-3 items-center before:absolute before:inset-x-0 before:-top-4 before:bg-gradient-to-t before:from-n-surface-1 before:from-10% before:dark:from-0% before:to-transparent before:h-4 before:pointer-events-none"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
<span class="min-w-0 text-sm font-normal line-clamp-1 text-n-slate-11">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line vue/no-unused-properties
|
||||||
active: {
|
active: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@ -24,10 +25,7 @@ const reauthorizationRequired = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span class="size-5 grid place-content-center rounded-full bg-n-alpha-2">
|
||||||
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" />
|
<ChannelIcon :inbox="inbox" class="size-3" />
|
||||||
</span>
|
</span>
|
||||||
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
<div class="flex-1 truncate min-w-0">{{ label }}</div>
|
||||||
|
|||||||
@ -39,7 +39,7 @@ const toggleSidebar = () => {
|
|||||||
<div
|
<div
|
||||||
v-if="!isConversationRoute"
|
v-if="!isConversationRoute"
|
||||||
id="mobile-sidebar-launcher"
|
id="mobile-sidebar-launcher"
|
||||||
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-in-out block md:hidden"
|
class="fixed bottom-4 ltr:left-4 rtl:right-4 z-40 transition-transform duration-200 ease-out block md:hidden"
|
||||||
:class="[
|
:class="[
|
||||||
{
|
{
|
||||||
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
'ltr:translate-x-48 rtl:-translate-x-48': isMobileSidebarOpen,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { h, ref, computed, onMounted } from 'vue';
|
import { h, ref, computed, onMounted } from 'vue';
|
||||||
import { provideSidebarContext } from './provider';
|
import { provideSidebarContext, useSidebarResize } from './provider';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
import { useKbd } from 'dashboard/composables/utils/useKbd';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
@ -8,6 +8,8 @@ import { useStore } from 'vuex';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||||
import { vOnClickOutside } from '@vueuse/components';
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
|
|
||||||
@ -15,7 +17,9 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import SidebarGroup from './SidebarGroup.vue';
|
import SidebarGroup from './SidebarGroup.vue';
|
||||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
|
import SidebarChangelogButton from './SidebarChangelogButton.vue';
|
||||||
import ChannelLeaf from './ChannelLeaf.vue';
|
import ChannelLeaf from './ChannelLeaf.vue';
|
||||||
|
import ChannelIcon from 'next/icon/ChannelIcon.vue';
|
||||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||||
import Logo from 'next/icon/Logo.vue';
|
import Logo from 'next/icon/Logo.vue';
|
||||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||||
@ -42,6 +46,22 @@ const { t } = useI18n();
|
|||||||
const isACustomBrandedInstance = useMapGetter(
|
const isACustomBrandedInstance = useMapGetter(
|
||||||
'globalConfig/isACustomBrandedInstance'
|
'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 => {
|
const toggleShortcutModalFn = show => {
|
||||||
if (show) {
|
if (show) {
|
||||||
@ -58,11 +78,85 @@ const expandedItem = ref(null);
|
|||||||
const setExpandedItem = name => {
|
const setExpandedItem = name => {
|
||||||
expandedItem.value = expandedItem.value === name ? null : 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({
|
provideSidebarContext({
|
||||||
expandedItem,
|
expandedItem,
|
||||||
setExpandedItem,
|
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 inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
const labels = useMapGetter('labels/getLabelsOnSidebar');
|
||||||
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
|
const dashboardApps = useMapGetter('dashboardApps/getAppsOnSidebar');
|
||||||
@ -194,6 +288,7 @@ const menuItems = computed(() => {
|
|||||||
children: sortedInboxes.value.map(inbox => ({
|
children: sortedInboxes.value.map(inbox => ({
|
||||||
name: `${inbox.name}-${inbox.id}`,
|
name: `${inbox.name}-${inbox.id}`,
|
||||||
label: inbox.name,
|
label: inbox.name,
|
||||||
|
icon: h(ChannelIcon, { inbox, class: 'size-[12px]' }),
|
||||||
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
to: accountScopedRoute('inbox_dashboard', { inbox_id: inbox.id }),
|
||||||
component: leafProps =>
|
component: leafProps =>
|
||||||
h(ChannelLeaf, {
|
h(ChannelLeaf, {
|
||||||
@ -212,7 +307,7 @@ const menuItems = computed(() => {
|
|||||||
name: `${label.title}-${label.id}`,
|
name: `${label.title}-${label.id}`,
|
||||||
label: label.title,
|
label: label.title,
|
||||||
icon: h('span', {
|
icon: h('span', {
|
||||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
class: `size-[8px] rounded-sm`,
|
||||||
style: { backgroundColor: label.color },
|
style: { backgroundColor: label.color },
|
||||||
}),
|
}),
|
||||||
to: accountScopedRoute('label_conversations', {
|
to: accountScopedRoute('label_conversations', {
|
||||||
@ -340,7 +435,7 @@ const menuItems = computed(() => {
|
|||||||
name: `${label.title}-${label.id}`,
|
name: `${label.title}-${label.id}`,
|
||||||
label: label.title,
|
label: label.title,
|
||||||
icon: h('span', {
|
icon: h('span', {
|
||||||
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
|
class: `size-[8px] rounded-sm`,
|
||||||
style: { backgroundColor: label.color },
|
style: { backgroundColor: label.color },
|
||||||
}),
|
}),
|
||||||
to: accountScopedRoute(
|
to: accountScopedRoute(
|
||||||
@ -504,12 +599,16 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-users',
|
icon: 'i-lucide-users',
|
||||||
to: accountScopedRoute('settings_teams_list'),
|
to: accountScopedRoute('settings_teams_list'),
|
||||||
},
|
},
|
||||||
{
|
...(hasAdvancedAssignment.value
|
||||||
name: 'Settings Agent Assignment',
|
? [
|
||||||
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
{
|
||||||
icon: 'i-lucide-user-cog',
|
name: 'Settings Agent Assignment',
|
||||||
to: accountScopedRoute('assignment_policy_index'),
|
label: t('SIDEBAR.AGENT_ASSIGNMENT'),
|
||||||
},
|
icon: 'i-lucide-user-cog',
|
||||||
|
to: accountScopedRoute('assignment_policy_index'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
name: 'Settings Inboxes',
|
name: 'Settings Inboxes',
|
||||||
label: t('SIDEBAR.INBOXES'),
|
label: t('SIDEBAR.INBOXES'),
|
||||||
@ -531,7 +630,7 @@ const menuItems = computed(() => {
|
|||||||
{
|
{
|
||||||
name: 'Settings Automation',
|
name: 'Settings Automation',
|
||||||
label: t('SIDEBAR.AUTOMATION'),
|
label: t('SIDEBAR.AUTOMATION'),
|
||||||
icon: 'i-lucide-workflow',
|
icon: 'i-lucide-repeat',
|
||||||
to: accountScopedRoute('automation_list'),
|
to: accountScopedRoute('automation_list'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -576,6 +675,12 @@ const menuItems = computed(() => {
|
|||||||
icon: 'i-lucide-clock-alert',
|
icon: 'i-lucide-clock-alert',
|
||||||
to: accountScopedRoute('sla_list'),
|
to: accountScopedRoute('sla_list'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'Conversation Workflow',
|
||||||
|
label: t('SIDEBAR.CONVERSATION_WORKFLOW'),
|
||||||
|
icon: 'i-lucide-workflow',
|
||||||
|
to: accountScopedRoute('conversation_workflow_index'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Settings Security',
|
name: 'Settings Security',
|
||||||
label: t('SIDEBAR.SECURITY'),
|
label: t('SIDEBAR.SECURITY'),
|
||||||
@ -617,32 +722,56 @@ const menuItems = computed(() => {
|
|||||||
closeMobileSidebar,
|
closeMobileSidebar,
|
||||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||||
]"
|
]"
|
||||||
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
|
class="bg-n-background flex flex-col text-sm pb-0.5 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||||
:class="[
|
:class="[
|
||||||
{
|
{
|
||||||
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
'shadow-lg md:shadow-none': isMobileSidebarOpen,
|
||||||
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
|
||||||
|
'transition-transform duration-200 ease-out md:transition-[width]':
|
||||||
|
!isResizing,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
|
:style="isMobile ? undefined : { width: `${sidebarWidth}px` }"
|
||||||
>
|
>
|
||||||
<section class="grid gap-2 mt-2 mb-4">
|
<section
|
||||||
<div class="flex gap-2 items-center px-2 min-w-0">
|
class="grid"
|
||||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
:class="isEffectivelyCollapsed ? 'mt-3 mb-6 gap-4' : 'mt-1 mb-4 gap-2'"
|
||||||
<Logo class="size-4" />
|
>
|
||||||
</div>
|
<div
|
||||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
class="flex gap-2 items-center min-w-0"
|
||||||
<SidebarAccountSwitcher
|
:class="{
|
||||||
class="flex-grow -mx-1 min-w-0"
|
'justify-center px-1': isEffectivelyCollapsed,
|
||||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
'px-2': !isEffectivelyCollapsed,
|
||||||
/>
|
}"
|
||||||
|
>
|
||||||
|
<template v-if="isEffectivelyCollapsed">
|
||||||
|
<SidebarAccountSwitcher
|
||||||
|
is-collapsed
|
||||||
|
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||||
|
<Logo class="size-4" />
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||||
|
<SidebarAccountSwitcher
|
||||||
|
class="flex-grow -mx-1 min-w-0"
|
||||||
|
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 px-2">
|
<div
|
||||||
|
class="flex gap-2"
|
||||||
|
:class="isEffectivelyCollapsed ? 'flex-col items-center' : 'px-2'"
|
||||||
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
|
v-if="!isEffectivelyCollapsed"
|
||||||
:to="{ name: 'search' }"
|
:to="{ name: 'search' }"
|
||||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-button-color transition-all duration-100 ease-out"
|
||||||
>
|
>
|
||||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-10" />
|
||||||
<span class="flex-grow text-left">
|
<span class="flex-grow text-start text-n-slate-10">
|
||||||
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
{{ t('COMBOBOX.SEARCH_PLACEHOLDER') }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@ -651,21 +780,41 @@ const menuItems = computed(() => {
|
|||||||
{{ searchShortcut }}
|
{{ searchShortcut }}
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</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">
|
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||||
<template #trigger="{ toggle }">
|
<template #trigger="{ toggle, isOpen }">
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-pen-line"
|
icon="i-lucide-pen-line"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
|
class="dark:hover:!bg-n-slate-9/30"
|
||||||
|
:class="[
|
||||||
|
isEffectivelyCollapsed
|
||||||
|
? '!size-8 !outline-n-weak !text-n-slate-11'
|
||||||
|
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||||
|
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||||
|
]"
|
||||||
@click="onComposeOpen(toggle)"
|
@click="onComposeOpen(toggle)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ComposeConversation>
|
</ComposeConversation>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
|
<nav
|
||||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
class="grid overflow-y-scroll flex-grow gap-2 pb-5 no-scrollbar min-w-0"
|
||||||
|
:class="isEffectivelyCollapsed ? 'px-1' : 'px-2'"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="flex flex-col gap-1 m-0 list-none min-w-0"
|
||||||
|
:class="{ 'items-center': isEffectivelyCollapsed }"
|
||||||
|
>
|
||||||
<SidebarGroup
|
<SidebarGroup
|
||||||
v-for="item in menuItems"
|
v-for="item in menuItems"
|
||||||
:key="item.name"
|
:key="item.name"
|
||||||
@ -674,21 +823,46 @@ const menuItems = computed(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<section
|
<section
|
||||||
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
class="flex relative flex-col flex-shrink-0 gap-1 justify-between items-center"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
class="pointer-events-none absolute inset-x-0 -top-[1.938rem] h-8 bg-gradient-to-t from-n-background to-transparent"
|
||||||
/>
|
/>
|
||||||
<SidebarChangelogCard
|
<SidebarChangelogCard
|
||||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
v-if="
|
||||||
|
isOnChatwootCloud &&
|
||||||
|
!isACustomBrandedInstance &&
|
||||||
|
!isEffectivelyCollapsed
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SidebarChangelogButton
|
||||||
|
v-if="
|
||||||
|
isOnChatwootCloud &&
|
||||||
|
!isACustomBrandedInstance &&
|
||||||
|
isEffectivelyCollapsed
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
class="p-1 flex-shrink-0 flex w-full z-50 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||||
|
:class="isEffectivelyCollapsed ? 'justify-center' : 'justify-between'"
|
||||||
>
|
>
|
||||||
<SidebarProfileMenu
|
<SidebarProfileMenu
|
||||||
|
:is-collapsed="isEffectivelyCollapsed"
|
||||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import ButtonNext from 'next/button/Button.vue';
|
import ButtonNext from 'next/button/Button.vue';
|
||||||
import Icon from 'next/icon/Icon.vue';
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import Logo from 'next/icon/Logo.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DropdownContainer,
|
DropdownContainer,
|
||||||
@ -13,6 +14,13 @@ import {
|
|||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from 'next/dropdown-menu/base';
|
} from 'next/dropdown-menu/base';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isCollapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['showCreateAccountModal']);
|
const emit = defineEmits(['showCreateAccountModal']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -45,7 +53,19 @@ const emitNewAccount = () => {
|
|||||||
<template>
|
<template>
|
||||||
<DropdownContainer>
|
<DropdownContainer>
|
||||||
<template #trigger="{ toggle, isOpen }">
|
<template #trigger="{ toggle, isOpen }">
|
||||||
|
<!-- Collapsed view: Logo trigger -->
|
||||||
<button
|
<button
|
||||||
|
v-if="isCollapsed"
|
||||||
|
class="grid flex-shrink-0 place-content-center p-2 rounded-lg cursor-pointer hover:bg-n-alpha-1"
|
||||||
|
:class="{ 'bg-n-alpha-1': isOpen }"
|
||||||
|
:title="currentAccount.name"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<Logo class="size-7" />
|
||||||
|
</button>
|
||||||
|
<!-- Expanded view: Account name trigger -->
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
id="sidebar-account-switcher"
|
id="sidebar-account-switcher"
|
||||||
:data-account-id="accountId"
|
:data-account-id="accountId"
|
||||||
aria-haspopup="listbox"
|
aria-haspopup="listbox"
|
||||||
@ -74,7 +94,7 @@ const emitNewAccount = () => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownBody
|
<DropdownBody
|
||||||
v-if="showAccountSwitcher"
|
v-if="showAccountSwitcher || isCollapsed"
|
||||||
class="min-w-80 z-50 max-h-[80vh] overflow-y-auto"
|
class="min-w-80 z-50 max-h-[80vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
<DropdownSection :title="t('SIDEBAR_ITEMS.SWITCH_ACCOUNT')">
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, useTemplateRef } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import { vOnClickOutside } from '@vueuse/components';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||||
|
|
||||||
|
const [isOpen, toggleOpen] = useToggle(false);
|
||||||
|
const changelogCard = useTemplateRef('changelogCard');
|
||||||
|
|
||||||
|
const isLoading = computed(() => changelogCard.value?.isLoading || false);
|
||||||
|
const hasArticles = computed(
|
||||||
|
() => changelogCard.value?.unDismissedPosts?.length > 0
|
||||||
|
);
|
||||||
|
const shouldShowButton = computed(() => !isLoading.value && hasArticles.value);
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
toggleOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-on-click-outside="closePopover" class="relative mb-2">
|
||||||
|
<Button
|
||||||
|
v-if="shouldShowButton"
|
||||||
|
icon="i-lucide-sparkles"
|
||||||
|
ghost
|
||||||
|
slate
|
||||||
|
:class="{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen }"
|
||||||
|
@click="toggleOpen()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Always render card so it can fetch data, control visibility with v-show -->
|
||||||
|
<div
|
||||||
|
v-show="isOpen && hasArticles"
|
||||||
|
class="absolute ltr:left-full rtl:right-full bottom-0 ltr:ml-4 rtl:mr-4 z-40 bg-transparent w-52"
|
||||||
|
>
|
||||||
|
<SidebarChangelogCard
|
||||||
|
ref="changelogCard"
|
||||||
|
class="[&>div]:!pb-0 [&>div]:!px-0 rounded-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -4,6 +4,10 @@ import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-car
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import changelogAPI from 'dashboard/api/changelog';
|
import changelogAPI from 'dashboard/api/changelog';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
});
|
||||||
|
|
||||||
const MAX_DISMISSED_SLUGS = 5;
|
const MAX_DISMISSED_SLUGS = 5;
|
||||||
|
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
@ -90,6 +94,11 @@ const handleImgClick = ({ index }) => {
|
|||||||
handleReadMore();
|
handleReadMore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
isLoading,
|
||||||
|
unDismissedPosts,
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchChangelog();
|
fetchChangelog();
|
||||||
});
|
});
|
||||||
@ -98,6 +107,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<GroupedStackedChangelogCard
|
<GroupedStackedChangelogCard
|
||||||
v-if="unDismissedPosts.length > 0"
|
v-if="unDismissedPosts.length > 0"
|
||||||
|
v-bind="$attrs"
|
||||||
:posts="unDismissedPosts"
|
:posts="unDismissedPosts"
|
||||||
:current-index="currentIndex"
|
:current-index="currentIndex"
|
||||||
:dismissing-slugs="dismissingCards"
|
:dismissing-slugs="dismissingCards"
|
||||||
|
|||||||
@ -0,0 +1,198 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useSidebarContext } from './provider';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
|
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: { type: String, required: true },
|
||||||
|
children: { type: Array, default: () => [] },
|
||||||
|
activeChild: { type: Object, default: undefined },
|
||||||
|
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAllowed, sidebarWidth } = useSidebarContext();
|
||||||
|
|
||||||
|
const expandedSubGroup = ref(null);
|
||||||
|
const popoverRef = ref(null);
|
||||||
|
const topPosition = ref(0);
|
||||||
|
const isRTL = useMapGetter('accounts/isRTL');
|
||||||
|
const skipTransition = ref(true);
|
||||||
|
|
||||||
|
const toggleSubGroup = name => {
|
||||||
|
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateAndClose = to => {
|
||||||
|
router.push(to);
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActive = child => props.activeChild?.name === child.name;
|
||||||
|
|
||||||
|
const getAccessibleSubChildren = children =>
|
||||||
|
children.filter(c => isAllowed(c.to));
|
||||||
|
|
||||||
|
const renderIcon = icon => ({
|
||||||
|
component: typeof icon === 'object' ? icon : Icon,
|
||||||
|
props: typeof icon === 'string' ? { icon } : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transition = computed(() =>
|
||||||
|
skipTransition.value
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
enterActiveClass: 'transition-all duration-200 ease-out',
|
||||||
|
enterFromClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||||
|
enterToClass: 'opacity-100 translate-y-0 max-h-96',
|
||||||
|
leaveActiveClass: 'transition-all duration-150 ease-in',
|
||||||
|
leaveFromClass: 'opacity-100 translate-y-0 max-h-96',
|
||||||
|
leaveToClass: 'opacity-0 -translate-y-2 max-h-0',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessibleChildren = computed(() => {
|
||||||
|
return props.children.filter(child => {
|
||||||
|
if (child.children) {
|
||||||
|
return child.children.some(subChild => isAllowed(subChild.to));
|
||||||
|
}
|
||||||
|
return child.to && isAllowed(child.to);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Auto-expand subgroup if active child is inside it
|
||||||
|
if (props.activeChild) {
|
||||||
|
const parentGroup = props.children.find(child =>
|
||||||
|
child.children?.some(subChild => subChild.name === props.activeChild.name)
|
||||||
|
);
|
||||||
|
if (parentGroup) {
|
||||||
|
expandedSubGroup.value = parentGroup.name;
|
||||||
|
// Wait for the subgroup expansion to render before measuring height
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.triggerRect) return;
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const popoverHeight = popoverRef.value?.offsetHeight || 300;
|
||||||
|
const { top: triggerTop } = props.triggerRect;
|
||||||
|
|
||||||
|
// Adjust position if popover would overflow viewport
|
||||||
|
topPosition.value =
|
||||||
|
triggerTop + popoverHeight > viewportHeight - 20
|
||||||
|
? Math.max(20, viewportHeight - popoverHeight - 20)
|
||||||
|
: triggerTop;
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
skipTransition.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TeleportWithDirection>
|
||||||
|
<div
|
||||||
|
ref="popoverRef"
|
||||||
|
class="fixed z-[100] min-w-[200px] max-w-[280px]"
|
||||||
|
:style="{
|
||||||
|
[isRTL ? 'right' : 'left']: `${sidebarWidth + 8}px`,
|
||||||
|
top: `${topPosition}px`,
|
||||||
|
}"
|
||||||
|
@mouseenter="emit('mouseenter')"
|
||||||
|
@mouseleave="emit('mouseleave')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-n-alpha-3 backdrop-blur-[100px] outline outline-1 -outline-offset-1 w-56 outline-n-weak rounded-xl shadow-lg py-2 px-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="px-2 py-1.5 text-xs font-medium text-n-slate-11 uppercase tracking-wider border-b border-n-weak mb-1"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
class="m-0 p-0 list-none max-h-[400px] overflow-y-auto no-scrollbar"
|
||||||
|
>
|
||||||
|
<template v-for="child in accessibleChildren" :key="child.name">
|
||||||
|
<!-- SubGroup with children -->
|
||||||
|
<li v-if="child.children" class="py-0.5">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
|
||||||
|
@click="toggleSubGroup(child.name)"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
v-if="child.icon"
|
||||||
|
:icon="child.icon"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
|
||||||
|
<span
|
||||||
|
class="size-3 transition-transform i-lucide-chevron-down"
|
||||||
|
:class="{
|
||||||
|
'rotate-180': expandedSubGroup === child.name,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Transition v-bind="transition">
|
||||||
|
<ul
|
||||||
|
v-if="expandedSubGroup === child.name"
|
||||||
|
class="m-0 p-0 list-none ltr:pl-4 rtl:pr-4 mt-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="subChild in getAccessibleSubChildren(child.children)"
|
||||||
|
:key="subChild.name"
|
||||||
|
class="py-0.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||||
|
:class="{
|
||||||
|
'text-n-slate-12 bg-n-alpha-2': isActive(subChild),
|
||||||
|
'text-n-slate-11 hover:bg-n-alpha-2':
|
||||||
|
!isActive(subChild),
|
||||||
|
}"
|
||||||
|
@click="navigateAndClose(subChild.to)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="renderIcon(subChild.icon).component"
|
||||||
|
v-if="subChild.icon"
|
||||||
|
v-bind="renderIcon(subChild.icon).props"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate">{{ subChild.label }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Transition>
|
||||||
|
</li>
|
||||||
|
<!-- Direct child item -->
|
||||||
|
<li v-else class="py-0.5">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-sm text-left rtl:text-right transition-colors duration-150 ease-out"
|
||||||
|
:class="{
|
||||||
|
'text-n-slate-12 bg-n-alpha-2': isActive(child),
|
||||||
|
'text-n-slate-11 hover:bg-n-alpha-2': !isActive(child),
|
||||||
|
}"
|
||||||
|
@click="navigateAndClose(child.to)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="renderIcon(child.icon).component"
|
||||||
|
v-if="child.icon"
|
||||||
|
v-bind="renderIcon(child.icon).props"
|
||||||
|
class="size-4 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 truncate">{{ child.label }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TeleportWithDirection>
|
||||||
|
</template>
|
||||||
@ -1,12 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, watch, nextTick } from 'vue';
|
import { computed, onMounted, onUnmounted, watch, nextTick, ref } from 'vue';
|
||||||
import { useSidebarContext } from './provider';
|
import { useSidebarContext, usePopoverState } from './provider';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import Policy from 'dashboard/components/policy.vue';
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
import Icon from 'next/icon/Icon.vue';
|
||||||
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
import SidebarGroupHeader from './SidebarGroupHeader.vue';
|
||||||
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
|
||||||
import SidebarSubGroup from './SidebarSubGroup.vue';
|
import SidebarSubGroup from './SidebarSubGroup.vue';
|
||||||
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
import SidebarGroupEmptyLeaf from './SidebarGroupEmptyLeaf.vue';
|
||||||
|
import SidebarCollapsedPopover from './SidebarCollapsedPopover.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
name: { type: String, required: true },
|
name: { type: String, required: true },
|
||||||
@ -25,8 +27,18 @@ const {
|
|||||||
resolvePermissions,
|
resolvePermissions,
|
||||||
resolveFeatureFlag,
|
resolveFeatureFlag,
|
||||||
isAllowed,
|
isAllowed,
|
||||||
|
isCollapsed,
|
||||||
|
isResizing,
|
||||||
} = useSidebarContext();
|
} = useSidebarContext();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activePopover,
|
||||||
|
setActivePopover,
|
||||||
|
closeActivePopover,
|
||||||
|
scheduleClose,
|
||||||
|
cancelClose,
|
||||||
|
} = usePopoverState();
|
||||||
|
|
||||||
const navigableChildren = computed(() => {
|
const navigableChildren = computed(() => {
|
||||||
return props.children?.flatMap(child => child.children || child) || [];
|
return props.children?.flatMap(child => child.children || child) || [];
|
||||||
});
|
});
|
||||||
@ -39,6 +51,54 @@ const hasChildren = computed(
|
|||||||
() => Array.isArray(props.children) && props.children.length > 0
|
() => Array.isArray(props.children) && props.children.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Use shared popover state - only one popover can be open at a time
|
||||||
|
const isPopoverOpen = computed(() => activePopover.value === props.name);
|
||||||
|
const triggerRef = ref(null);
|
||||||
|
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
|
||||||
|
|
||||||
|
const openPopover = () => {
|
||||||
|
if (triggerRef.value) {
|
||||||
|
const rect = triggerRef.value.getBoundingClientRect();
|
||||||
|
triggerRect.value = {
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left,
|
||||||
|
bottom: rect.bottom,
|
||||||
|
right: rect.right,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setActivePopover(props.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
if (activePopover.value === props.name) {
|
||||||
|
closeActivePopover();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!hasChildren.value || isResizing.value) return;
|
||||||
|
cancelClose();
|
||||||
|
openPopover();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (!hasChildren.value) return;
|
||||||
|
scheduleClose(200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverMouseEnter = () => {
|
||||||
|
cancelClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverMouseLeave = () => {
|
||||||
|
scheduleClose(100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close popover when mouse leaves the window
|
||||||
|
const handleWindowBlur = () => {
|
||||||
|
closeActivePopover();
|
||||||
|
};
|
||||||
|
|
||||||
const accessibleItems = computed(() => {
|
const accessibleItems = computed(() => {
|
||||||
if (!hasChildren.value) return [];
|
if (!hasChildren.value) return [];
|
||||||
return props.children.filter(child => {
|
return props.children.filter(child => {
|
||||||
@ -109,6 +169,13 @@ const hasActiveChild = computed(() => {
|
|||||||
return activeChild.value !== undefined;
|
return activeChild.value !== undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleCollapsedClick = () => {
|
||||||
|
if (hasChildren.value && hasAccessibleChildren.value) {
|
||||||
|
const firstItem = accessibleItems.value[0];
|
||||||
|
router.push(firstItem.to);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleTrigger = () => {
|
const toggleTrigger = () => {
|
||||||
if (
|
if (
|
||||||
hasAccessibleChildren.value &&
|
hasAccessibleChildren.value &&
|
||||||
@ -127,6 +194,13 @@ onMounted(async () => {
|
|||||||
if (hasActiveChild.value) {
|
if (hasActiveChild.value) {
|
||||||
setExpandedItem(props.name);
|
setExpandedItem(props.name);
|
||||||
}
|
}
|
||||||
|
window.addEventListener('blur', handleWindowBlur);
|
||||||
|
document.addEventListener('mouseleave', handleWindowBlur);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('blur', handleWindowBlur);
|
||||||
|
document.removeEventListener('mouseleave', handleWindowBlur);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -147,45 +221,82 @@ watch(
|
|||||||
:permissions="resolvePermissions(to)"
|
:permissions="resolvePermissions(to)"
|
||||||
:feature-flag="resolveFeatureFlag(to)"
|
:feature-flag="resolveFeatureFlag(to)"
|
||||||
as="li"
|
as="li"
|
||||||
class="grid gap-1 text-sm cursor-pointer select-none"
|
class="grid gap-1 text-sm cursor-pointer select-none min-w-0"
|
||||||
>
|
>
|
||||||
<SidebarGroupHeader
|
<!-- Collapsed State -->
|
||||||
:icon
|
<template v-if="isCollapsed">
|
||||||
:name
|
<div
|
||||||
:label
|
class="relative"
|
||||||
:to
|
@mouseenter="handleMouseEnter"
|
||||||
:getter-keys="getterKeys"
|
@mouseleave="handleMouseLeave"
|
||||||
:is-active="isActive"
|
>
|
||||||
:has-active-child="hasActiveChild"
|
<component
|
||||||
:expandable="hasChildren"
|
:is="to && !hasChildren ? 'router-link' : 'button'"
|
||||||
:is-expanded="isExpanded"
|
ref="triggerRef"
|
||||||
@toggle="toggleTrigger"
|
:to="to && !hasChildren ? to : undefined"
|
||||||
/>
|
type="button"
|
||||||
<ul
|
class="flex items-center justify-center size-10 rounded-lg"
|
||||||
v-if="hasChildren"
|
:class="{
|
||||||
v-show="isExpanded || hasActiveChild"
|
'text-n-slate-12 bg-n-alpha-2': isActive || hasActiveChild,
|
||||||
class="grid m-0 list-none sidebar-group-children"
|
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||||
>
|
}"
|
||||||
<template v-for="child in children" :key="child.name">
|
:title="label"
|
||||||
<SidebarSubGroup
|
@click="hasChildren ? handleCollapsedClick() : undefined"
|
||||||
v-if="child.children"
|
>
|
||||||
:label="child.label"
|
<Icon v-if="icon" :icon="icon" class="size-4" />
|
||||||
:icon="child.icon"
|
</component>
|
||||||
:children="child.children"
|
<SidebarCollapsedPopover
|
||||||
:is-expanded="isExpanded"
|
v-if="hasChildren && isPopoverOpen"
|
||||||
|
:label="label"
|
||||||
|
:children="children"
|
||||||
:active-child="activeChild"
|
:active-child="activeChild"
|
||||||
|
:trigger-rect="triggerRect"
|
||||||
|
@close="closePopover"
|
||||||
|
@mouseenter="handlePopoverMouseEnter"
|
||||||
|
@mouseleave="handlePopoverMouseLeave"
|
||||||
/>
|
/>
|
||||||
<SidebarGroupLeaf
|
</div>
|
||||||
v-else-if="isAllowed(child.to)"
|
</template>
|
||||||
v-show="isExpanded || activeChild?.name === child.name"
|
<!-- Expanded State -->
|
||||||
v-bind="child"
|
<template v-else>
|
||||||
:active="activeChild?.name === child.name"
|
<SidebarGroupHeader
|
||||||
/>
|
:icon
|
||||||
</template>
|
:name
|
||||||
</ul>
|
:label
|
||||||
<ul v-else-if="isExpandable && isExpanded">
|
:to
|
||||||
<SidebarGroupEmptyLeaf />
|
:getter-keys="getterKeys"
|
||||||
</ul>
|
:is-active="isActive"
|
||||||
|
:has-active-child="hasActiveChild"
|
||||||
|
:expandable="hasChildren"
|
||||||
|
:is-expanded="isExpanded"
|
||||||
|
@toggle="toggleTrigger"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="hasChildren"
|
||||||
|
v-show="isExpanded || hasActiveChild"
|
||||||
|
class="grid m-0 list-none sidebar-group-children min-w-0"
|
||||||
|
>
|
||||||
|
<template v-for="child in children" :key="child.name">
|
||||||
|
<SidebarSubGroup
|
||||||
|
v-if="child.children"
|
||||||
|
:label="child.label"
|
||||||
|
:icon="child.icon"
|
||||||
|
:children="child.children"
|
||||||
|
:is-expanded="isExpanded"
|
||||||
|
:active-child="activeChild"
|
||||||
|
/>
|
||||||
|
<SidebarGroupLeaf
|
||||||
|
v-else-if="isAllowed(child.to)"
|
||||||
|
v-show="isExpanded || activeChild?.name === child.name"
|
||||||
|
v-bind="child"
|
||||||
|
:active="activeChild?.name === child.name"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
<ul v-else-if="isExpandable && isExpanded">
|
||||||
|
<SidebarGroupEmptyLeaf />
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
</Policy>
|
</Policy>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -26,13 +26,13 @@ const count = computed(() =>
|
|||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
:is="to ? 'router-link' : 'div'"
|
:is="to ? 'router-link' : 'div'"
|
||||||
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 min-w-0"
|
class="flex items-center gap-2 px-1.5 py-1 rounded-lg h-8 min-w-0"
|
||||||
role="button"
|
role="button"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
:to="to"
|
:to="to"
|
||||||
:title="label"
|
:title="label"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-blue-text bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
'text-n-slate-12 bg-n-alpha-2 font-medium': isActive && !hasActiveChild,
|
||||||
'text-n-slate-12 font-medium': hasActiveChild,
|
'text-n-slate-12 font-medium': hasActiveChild,
|
||||||
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
'text-n-slate-11 hover:bg-n-alpha-2': !isActive && !hasActiveChild,
|
||||||
}"
|
}"
|
||||||
@ -45,15 +45,21 @@ const count = computed(() =>
|
|||||||
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 flex-grow min-w-0">
|
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
|
||||||
<span class="text-sm font-medium leading-5 truncate">
|
<span
|
||||||
|
class="truncate"
|
||||||
|
:class="{
|
||||||
|
'text-body-main': !isActive,
|
||||||
|
'font-medium text-sm': isActive || hasActiveChild,
|
||||||
|
}"
|
||||||
|
>
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="dynamicCount && !expandable"
|
v-if="dynamicCount && !expandable"
|
||||||
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-blue-text outline-n-slate-6': isActive,
|
'text-n-slate-12 outline-n-slate-6': isActive,
|
||||||
'text-n-slate-11 outline-n-strong': !isActive,
|
'text-n-slate-11 outline-n-strong': !isActive,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -25,15 +25,15 @@ const shouldRenderComponent = computed(() => {
|
|||||||
:permissions="resolvePermissions(to)"
|
:permissions="resolvePermissions(to)"
|
||||||
:feature-flag="resolveFeatureFlag(to)"
|
:feature-flag="resolveFeatureFlag(to)"
|
||||||
as="li"
|
as="li"
|
||||||
class="py-0.5 ltr:pl-3 rtl:pr-3 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0"
|
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
:is="to ? 'router-link' : 'div'"
|
:is="to ? 'router-link' : 'div'"
|
||||||
:to="to"
|
:to="to"
|
||||||
:title="label"
|
:title="label"
|
||||||
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg max-w-[9.438rem] hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group"
|
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
|
||||||
:class="{
|
:class="{
|
||||||
'text-n-blue-text bg-n-alpha-2 active': active,
|
'text-n-slate-12 bg-n-alpha-2 active': active,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<component
|
<component
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user