Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c86153b4e9 |
+3
-47
@@ -76,7 +76,7 @@ jobs:
|
||||
bundle install
|
||||
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
node-version: '23.7'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
node-version: '23.7'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
steps:
|
||||
- checkout
|
||||
- node/install:
|
||||
node-version: '24.13'
|
||||
node-version: '23.7'
|
||||
- node/install-pnpm
|
||||
- node/install-packages:
|
||||
pkg-manager: pnpm
|
||||
@@ -218,49 +218,6 @@ jobs:
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
|
||||
# Install and configure OpenSearch
|
||||
- run:
|
||||
name: Install OpenSearch
|
||||
command: |
|
||||
# Download and install OpenSearch 2.11.0 (compatible with Elasticsearch 7.x clients)
|
||||
wget https://artifacts.opensearch.org/releases/bundle/opensearch/2.11.0/opensearch-2.11.0-linux-x64.tar.gz
|
||||
tar -xzf opensearch-2.11.0-linux-x64.tar.gz
|
||||
sudo mv opensearch-2.11.0 /opt/opensearch
|
||||
|
||||
- run:
|
||||
name: Configure and Start OpenSearch
|
||||
command: |
|
||||
# Configure OpenSearch for single-node testing
|
||||
cat > /opt/opensearch/config/opensearch.yml \<< EOF
|
||||
cluster.name: chatwoot-test
|
||||
node.name: node-1
|
||||
network.host: 0.0.0.0
|
||||
http.port: 9200
|
||||
discovery.type: single-node
|
||||
plugins.security.disabled: true
|
||||
EOF
|
||||
|
||||
# Set ownership and permissions
|
||||
sudo chown -R $USER:$USER /opt/opensearch
|
||||
|
||||
# Start OpenSearch in background
|
||||
/opt/opensearch/bin/opensearch -d -p /tmp/opensearch.pid
|
||||
|
||||
- run:
|
||||
name: Wait for OpenSearch to be ready
|
||||
command: |
|
||||
echo "Waiting for OpenSearch to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://localhost:9200/_cluster/health | grep -q '"status"'; then
|
||||
echo "OpenSearch is ready!"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
echo "OpenSearch failed to start"
|
||||
exit 1
|
||||
|
||||
# Configure environment and database
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
@@ -277,7 +234,6 @@ jobs:
|
||||
sed -i -e '/POSTGRES_USERNAME/ s/=.*/=chatwoot/' .env
|
||||
sed -i -e "/POSTGRES_PASSWORD/ s/=.*/=$pg_pass/" .env
|
||||
echo -en "\nINSTALLATION_ENV=circleci" >> ".env"
|
||||
echo -en "\nOPENSEARCH_URL=http://localhost:9200" >> ".env"
|
||||
|
||||
# Database setup
|
||||
- run:
|
||||
|
||||
@@ -10,7 +10,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile.base
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '24.13.0'
|
||||
NODE_VERSION: '23.7.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
args:
|
||||
VARIANT: 'ubuntu-22.04'
|
||||
NODE_VERSION: '24.13.0'
|
||||
NODE_VERSION: '23.7.0'
|
||||
RUBY_VERSION: '3.4.4'
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: '1000'
|
||||
|
||||
@@ -274,5 +274,3 @@ AZURE_APP_SECRET=
|
||||
# Set to true if you want to remove stale contact inboxes
|
||||
# contact_inboxes with no conversation older than 90 days will be removed
|
||||
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
|
||||
|
||||
# REDIS_ALFRED_SIZE=10
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 23
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 23
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 23
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 23
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 23
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: pnpm
|
||||
|
||||
@@ -47,14 +47,8 @@
|
||||
- Avoid writing specs unless explicitly asked
|
||||
- Remove dead/unreachable/unused code
|
||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||
- 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
|
||||
|
||||
## Commit Messages
|
||||
|
||||
- Prefer Conventional Commits: `type(scope): subject` (scope optional)
|
||||
- Example: `feat(auth): add user authentication`
|
||||
- Don't reference Claude in commit messages
|
||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||
|
||||
## Project-Specific
|
||||
|
||||
|
||||
+15
-20
@@ -140,27 +140,24 @@ GEM
|
||||
actionmailbox (>= 7.1.0)
|
||||
aws-sdk-s3 (~> 1, >= 1.123.0)
|
||||
aws-sdk-sns (~> 1, >= 1.61.0)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1198.0)
|
||||
aws-sdk-core (3.240.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
base64
|
||||
bigdecimal
|
||||
aws-eventstream (1.2.0)
|
||||
aws-partitions (1.760.0)
|
||||
aws-sdk-core (3.188.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.118.0)
|
||||
aws-sdk-core (~> 3, >= 3.239.1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.208.0)
|
||||
aws-sdk-core (~> 3, >= 3.234.0)
|
||||
aws-sdk-kms (1.64.0)
|
||||
aws-sdk-core (~> 3, >= 3.165.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sdk-s3 (1.126.0)
|
||||
aws-sdk-core (~> 3, >= 3.174.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (~> 1.4)
|
||||
aws-sdk-sns (1.70.0)
|
||||
aws-sdk-core (~> 3, >= 3.188.0)
|
||||
aws-sigv4 (~> 1.1)
|
||||
aws-sigv4 (1.12.1)
|
||||
aws-sigv4 (1.5.2)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
barnes (0.0.9)
|
||||
multi_json (~> 1)
|
||||
@@ -441,8 +438,7 @@ GEM
|
||||
http-cookie (1.0.5)
|
||||
domain_name (~> 0.5)
|
||||
http-form_data (2.3.0)
|
||||
httparty (0.24.0)
|
||||
csv
|
||||
httparty (0.21.0)
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
httpclient (2.8.3)
|
||||
@@ -556,8 +552,7 @@ GEM
|
||||
ruby2_keywords
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.15.0)
|
||||
multi_xml (0.8.0)
|
||||
bigdecimal (>= 3.1, < 5)
|
||||
multi_xml (0.6.0)
|
||||
multipart-post (2.3.0)
|
||||
mutex_m (0.3.0)
|
||||
neighbor (0.2.3)
|
||||
|
||||
@@ -8,6 +8,7 @@ ___
|
||||
The modern customer support platform, an open-source alternative to Intercom, Zendesk, Salesforce Service Cloud etc.
|
||||
|
||||
<p>
|
||||
<a href="https://codeclimate.com/github/chatwoot/chatwoot/maintainability"><img src="https://api.codeclimate.com/v1/badges/e6e3f66332c91e5a4c0c/maintainability" alt="Maintainability"></a>
|
||||
<img src="https://img.shields.io/circleci/build/github/chatwoot/chatwoot" alt="CircleCI Badge">
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/pulls/chatwoot/chatwoot" alt="Docker Pull Badge"></a>
|
||||
<a href="https://hub.docker.com/r/chatwoot/chatwoot/"><img src="https://img.shields.io/docker/cloud/build/chatwoot/chatwoot" alt="Docker Build Badge"></a>
|
||||
@@ -136,4 +137,4 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
<a href="https://github.com/chatwoot/chatwoot/graphs/contributors"><img src="https://opencollective.com/chatwoot/contributors.svg?width=890&button=false" /></a>
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
*Chatwoot* © 2017-2025, Chatwoot Inc - Released under the MIT License.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.10.1
|
||||
4.8.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.5.0
|
||||
3.4.3
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
class V2::Reports::ChannelSummaryBuilder
|
||||
include DateRangeHelper
|
||||
|
||||
pattr_initialize [:account!, :params!]
|
||||
|
||||
def build
|
||||
conversations_by_channel_and_status.transform_values { |status_counts| build_channel_stats(status_counts) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversations_by_channel_and_status
|
||||
account.conversations
|
||||
.joins(:inbox)
|
||||
.where(created_at: range)
|
||||
.group('inboxes.channel_type', 'conversations.status')
|
||||
.count
|
||||
.each_with_object({}) do |((channel_type, status), count), grouped|
|
||||
grouped[channel_type] ||= {}
|
||||
grouped[channel_type][status] = count
|
||||
end
|
||||
end
|
||||
|
||||
def build_channel_stats(status_counts)
|
||||
open_count = status_counts['open'] || 0
|
||||
resolved_count = status_counts['resolved'] || 0
|
||||
pending_count = status_counts['pending'] || 0
|
||||
snoozed_count = status_counts['snoozed'] || 0
|
||||
|
||||
{
|
||||
open: open_count,
|
||||
resolved: resolved_count,
|
||||
pending: pending_count,
|
||||
snoozed: snoozed_count,
|
||||
total: open_count + resolved_count + pending_count + snoozed_count
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,4 @@
|
||||
class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseController
|
||||
include AttachmentConcern
|
||||
|
||||
before_action :check_authorization
|
||||
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
|
||||
|
||||
@@ -11,32 +9,25 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def show; end
|
||||
|
||||
def create
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = actions
|
||||
@automation_rule.actions = params[:actions]
|
||||
@automation_rule.conditions = params[:conditions]
|
||||
|
||||
return render_could_not_create_error(@automation_rule.errors.messages) unless @automation_rule.valid?
|
||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||
|
||||
@automation_rule.save!
|
||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||
process_attachments
|
||||
@automation_rule
|
||||
end
|
||||
|
||||
def update
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @automation_rule)
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@automation_rule.assign_attributes(automation_rules_permit)
|
||||
@automation_rule.actions = actions if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
blobs.each { |blob| @automation_rule.files.attach(blob) }
|
||||
automation_rule_update
|
||||
process_attachments
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render_could_not_create_error(@automation_rule.errors.messages)
|
||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,11 +43,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@automation_rule.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def automation_rule_update
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.conditions = params[:conditions] if params[:conditions]
|
||||
@automation_rule.save!
|
||||
end
|
||||
|
||||
def automation_rules_permit
|
||||
params.permit(
|
||||
:name, :description, :event_name, :active,
|
||||
:name, :description, :event_name, :account_id, :active,
|
||||
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :authorize_account_update, only: [:update]
|
||||
|
||||
def show
|
||||
render json: preferences_payload
|
||||
end
|
||||
|
||||
def update
|
||||
params_to_update = captain_params
|
||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
||||
@current_account.save!
|
||||
|
||||
render json: preferences_payload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preferences_payload
|
||||
{
|
||||
providers: Llm::Models.providers,
|
||||
models: Llm::Models.models,
|
||||
features: features_with_account_preferences
|
||||
}
|
||||
end
|
||||
|
||||
def authorize_account_update
|
||||
authorize @current_account, :update?
|
||||
end
|
||||
|
||||
def captain_params
|
||||
permitted = {}
|
||||
permitted[:captain_models] = merged_captain_models if params[:captain_models].present?
|
||||
permitted[:captain_features] = merged_captain_features if params[:captain_features].present?
|
||||
permitted
|
||||
end
|
||||
|
||||
def merged_captain_models
|
||||
existing_models = @current_account.captain_models || {}
|
||||
existing_models.merge(permitted_captain_models)
|
||||
end
|
||||
|
||||
def merged_captain_features
|
||||
existing_features = @current_account.captain_features || {}
|
||||
existing_features.merge(permitted_captain_features)
|
||||
end
|
||||
|
||||
def permitted_captain_models
|
||||
params.require(:captain_models).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def permitted_captain_features
|
||||
params.require(:captain_features).permit(
|
||||
:editor, :assistant, :copilot, :label_suggestion,
|
||||
:audio_transcription, :help_center_search
|
||||
).to_h.stringify_keys
|
||||
end
|
||||
|
||||
def features_with_account_preferences
|
||||
preferences = Current.account.captain_preferences
|
||||
account_features = preferences[:features] || {}
|
||||
account_models = preferences[:models] || {}
|
||||
|
||||
Llm::Models.feature_keys.index_with do |feature_key|
|
||||
config = Llm::Models.feature_config(feature_key)
|
||||
config.merge(
|
||||
enabled: account_features[feature_key] == true,
|
||||
selected: account_models[feature_key] || config[:default]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,7 +64,7 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
|
||||
def permitted_params
|
||||
params.require(:twilio_channel).permit(
|
||||
:messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||
:account_id, :messaging_service_sid, :phone_number, :account_sid, :auth_token, :name, :medium, :api_key_sid
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,5 +50,3 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::CsatSurveyResponsesController.prepend_mod_with('Api::V1::Accounts::CsatSurveyResponsesController')
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseController
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
|
||||
before_action :fetch_inbox
|
||||
before_action :validate_whatsapp_channel
|
||||
|
||||
def show
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
result = service.template_status
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return render json: { template_exists: false } unless template
|
||||
|
||||
if result[:service_error]
|
||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||
else
|
||||
render json: result
|
||||
end
|
||||
template_name = template['name'] || Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
status_result = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
|
||||
render_template_status_response(status_result, template_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching CSAT template status: #{e.message}"
|
||||
render json: { error: e.message }, status: :internal_server_error
|
||||
end
|
||||
|
||||
def create
|
||||
template_params = extract_template_params
|
||||
return render_missing_message_error if template_params[:message].blank?
|
||||
|
||||
service = CsatTemplateManagementService.new(@inbox)
|
||||
result = service.create_template(template_params)
|
||||
# Delete existing template even though we are using a new one.
|
||||
# We don't want too many templates in the business portfolio, but the create operation shouldn't fail if deletion fails.
|
||||
delete_existing_template_if_needed
|
||||
|
||||
result = create_template_via_provider(template_params)
|
||||
render_template_creation_result(result)
|
||||
rescue ActionController::ParameterMissing
|
||||
render json: { error: 'Template parameters are required' }, status: :unprocessable_entity
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error creating CSAT template: #{e.message}"
|
||||
render json: { error: 'Template creation failed' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
@@ -32,9 +43,9 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
end
|
||||
|
||||
def validate_whatsapp_channel
|
||||
return if @inbox.whatsapp? || @inbox.twilio_whatsapp?
|
||||
return if @inbox.whatsapp?
|
||||
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp and Twilio WhatsApp channels' },
|
||||
render json: { error: 'CSAT template operations only available for WhatsApp channels' },
|
||||
status: :bad_request
|
||||
end
|
||||
|
||||
@@ -46,36 +57,35 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
render json: { error: 'Message is required' }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def create_template_via_provider(template_params)
|
||||
template_config = {
|
||||
message: template_params[:message],
|
||||
button_text: template_params[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
base_url: ENV.fetch('FRONTEND_URL', 'http://localhost:3000'),
|
||||
language: template_params[:language] || DEFAULT_LANGUAGE,
|
||||
template_name: Whatsapp::CsatTemplateNameService.csat_template_name(@inbox.id)
|
||||
}
|
||||
|
||||
@inbox.channel.provider_service.create_csat_template(template_config)
|
||||
end
|
||||
|
||||
def render_template_creation_result(result)
|
||||
if result[:success]
|
||||
render_successful_template_creation(result)
|
||||
elsif result[:service_error]
|
||||
render json: { error: result[:service_error] }, status: :internal_server_error
|
||||
else
|
||||
render_failed_template_creation(result)
|
||||
end
|
||||
end
|
||||
|
||||
def render_successful_template_creation(result)
|
||||
if @inbox.twilio_whatsapp?
|
||||
render json: {
|
||||
template: {
|
||||
friendly_name: result[:friendly_name],
|
||||
content_sid: result[:content_sid],
|
||||
status: result[:status] || 'pending',
|
||||
language: result[:language] || 'en'
|
||||
}
|
||||
}, status: :created
|
||||
else
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || 'en'
|
||||
}
|
||||
}, status: :created
|
||||
end
|
||||
render json: {
|
||||
template: {
|
||||
name: result[:template_name],
|
||||
template_id: result[:template_id],
|
||||
status: 'PENDING',
|
||||
language: result[:language] || DEFAULT_LANGUAGE
|
||||
}
|
||||
}, status: :created
|
||||
end
|
||||
|
||||
def render_failed_template_creation(result)
|
||||
@@ -88,6 +98,45 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def delete_existing_template_if_needed
|
||||
template = @inbox.csat_config&.dig('template')
|
||||
return true if template.blank?
|
||||
|
||||
template_name = template['name']
|
||||
return true if template_name.blank?
|
||||
|
||||
template_status = @inbox.channel.provider_service.get_template_status(template_name)
|
||||
return true unless template_status[:success]
|
||||
|
||||
deletion_result = @inbox.channel.provider_service.delete_csat_template(template_name)
|
||||
if deletion_result[:success]
|
||||
Rails.logger.info "Deleted existing CSAT template '#{template_name}' for inbox #{@inbox.id}"
|
||||
true
|
||||
else
|
||||
Rails.logger.warn "Failed to delete existing CSAT template '#{template_name}' for inbox #{@inbox.id}: #{deletion_result[:response_body]}"
|
||||
false
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error during template deletion for inbox #{@inbox.id}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def render_template_status_response(status_result, template_name)
|
||||
if status_result[:success]
|
||||
render json: {
|
||||
template_exists: true,
|
||||
template_name: template_name,
|
||||
status: status_result[:template][:status],
|
||||
template_id: status_result[:template][:id]
|
||||
}
|
||||
else
|
||||
render json: {
|
||||
template_exists: false,
|
||||
error: 'Template not found'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_whatsapp_error(response_body)
|
||||
return { user_message: nil, technical_details: nil } if response_body.blank?
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
:lock_to_single_conversation, :portal_id, :sender_name_type, :business_name,
|
||||
{ csat_config: [:display_type, :message, :button_text, :language,
|
||||
{ survey_rules: [:operator, { values: [] }],
|
||||
template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }]
|
||||
template: [:name, :template_id, :created_at, :language] }] }]
|
||||
end
|
||||
|
||||
def permitted_params(channel_attributes = [])
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
include AttachmentConcern
|
||||
|
||||
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
|
||||
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
|
||||
|
||||
@@ -13,32 +11,26 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def create
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions])
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.actions = actions
|
||||
@macro.actions = params[:actions]
|
||||
|
||||
return render_could_not_create_error(@macro.errors.messages) unless @macro.valid?
|
||||
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
|
||||
|
||||
@macro.save!
|
||||
blobs.each { |blob| @macro.files.attach(blob) }
|
||||
process_attachments
|
||||
@macro
|
||||
end
|
||||
|
||||
def update
|
||||
blobs, actions, error = validate_and_prepare_attachments(params[:actions], @macro)
|
||||
return render_could_not_create_error(error) if error
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@macro.assign_attributes(macros_with_user)
|
||||
@macro.update!(macros_with_user)
|
||||
@macro.set_visibility(current_user, permitted_params)
|
||||
@macro.actions = actions if params[:actions]
|
||||
process_attachments
|
||||
@macro.save!
|
||||
blobs.each { |blob| @macro.files.attach(blob) }
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render_could_not_create_error(@macro.errors.messages)
|
||||
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -55,9 +47,20 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def process_attachments
|
||||
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@macro.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(
|
||||
:name, :visibility,
|
||||
:name, :account_id, :visibility,
|
||||
actions: [:action_name, { action_params: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
@@ -62,7 +62,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def process_attached_logo
|
||||
blob_id = params[:blob_id]
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id)
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@portal.logo.attach(blob)
|
||||
end
|
||||
|
||||
@@ -78,7 +78,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:id, :account_id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
@@ -28,7 +28,5 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
|
||||
search_type: search_type,
|
||||
params: params
|
||||
).perform
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
|
||||
include Tiktok::IntegrationHelper
|
||||
|
||||
def create
|
||||
redirect_url = Tiktok::AuthClient.authorize_url(
|
||||
state: generate_tiktok_token(Current.account.id)
|
||||
)
|
||||
|
||||
if redirect_url
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -59,7 +59,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def render_success(file_blob)
|
||||
render json: { file_url: url_for(file_blob), blob_id: file_blob.signed_id }
|
||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def render_error(message, status)
|
||||
|
||||
@@ -92,8 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
end
|
||||
|
||||
def settings_params
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
|
||||
conversation_required_attributes: [])
|
||||
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
|
||||
@@ -38,11 +38,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
|
||||
end
|
||||
|
||||
def conversations_summary
|
||||
@report_data = generate_conversations_report
|
||||
generate_csv('conversations_summary_report', 'api/v2/accounts/reports/conversations_summary')
|
||||
end
|
||||
|
||||
def conversation_traffic
|
||||
@report_data = generate_conversations_heatmap_report
|
||||
timezone_offset = (params[:timezone_offset] || 0).to_f
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
@@ -18,12 +18,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
render_report_with(V2::Reports::LabelSummaryBuilder)
|
||||
end
|
||||
|
||||
def channel
|
||||
return render_could_not_create_error(I18n.t('errors.reports.date_range_too_long')) if date_range_too_long?
|
||||
|
||||
render_report_with(V2::Reports::ChannelSummaryBuilder)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
@@ -46,12 +40,4 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
def permitted_params
|
||||
params.permit(:since, :until, :business_hours)
|
||||
end
|
||||
|
||||
def date_range_too_long?
|
||||
return false if permitted_params[:since].blank? || permitted_params[:until].blank?
|
||||
|
||||
since_time = Time.zone.at(permitted_params[:since].to_i)
|
||||
until_time = Time.zone.at(permitted_params[:until].to_i)
|
||||
(until_time - since_time) > 6.months
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
module AttachmentConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def validate_and_prepare_attachments(actions, record = nil)
|
||||
blobs = []
|
||||
return [blobs, actions, nil] if actions.blank?
|
||||
|
||||
sanitized = actions.map do |action|
|
||||
next action unless action[:action_name] == 'send_attachment'
|
||||
|
||||
result = process_attachment_action(action, record, blobs)
|
||||
return [nil, nil, I18n.t('errors.attachments.invalid')] unless result
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
[blobs, sanitized, nil]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_attachment_action(action, record, blobs)
|
||||
blob_id = action[:action_params].first
|
||||
blob = ActiveStorage::Blob.find_signed(blob_id.to_s)
|
||||
|
||||
return action.merge(action_params: [blob.id]).tap { blobs << blob } if blob.present?
|
||||
return action if blob_already_attached?(record, blob_id)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def blob_already_attached?(record, blob_id)
|
||||
record&.files&.any? { |f| f.blob_id == blob_id.to_i }
|
||||
end
|
||||
end
|
||||
@@ -16,7 +16,7 @@ class DashboardController < ActionController::Base
|
||||
CHATWOOT_INBOX_TOKEN
|
||||
API_CHANNEL_NAME
|
||||
API_CHANNEL_THUMBNAIL
|
||||
CLOUD_ANALYTICS_TOKEN
|
||||
ANALYTICS_TOKEN
|
||||
DIRECT_UPLOADS_ENABLED
|
||||
MAXIMUM_FILE_UPLOAD_SIZE
|
||||
HCAPTCHA_SITE_KEY
|
||||
@@ -73,7 +73,6 @@ class DashboardController < ActionController::Base
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
TIKTOK_APP_ID: GlobalConfigService.load('TIKTOK_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v18.0'),
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
|
||||
@@ -46,11 +46,9 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
|
||||
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'tiktok' => %w[TIKTOK_APP_ID TIKTOK_APP_SECRET],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN],
|
||||
'captain' => %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_OPEN_AI_ENDPOINT]
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
class Tiktok::CallbacksController < ApplicationController
|
||||
include Tiktok::IntegrationHelper
|
||||
|
||||
def show
|
||||
return handle_authorization_error if params[:error].present?
|
||||
return handle_ungranted_scopes_error unless all_scopes_granted?
|
||||
|
||||
process_successful_authorization
|
||||
rescue StandardError => e
|
||||
handle_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def all_scopes_granted?
|
||||
granted_scopes = short_term_access_token[:scope].to_s.split(',')
|
||||
(Tiktok::AuthClient::REQUIRED_SCOPES - granted_scopes).blank?
|
||||
end
|
||||
|
||||
def process_successful_authorization
|
||||
inbox, already_exists = find_or_create_inbox
|
||||
|
||||
if already_exists
|
||||
redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id)
|
||||
else
|
||||
redirect_to app_tiktok_inbox_agents_url(account_id: account_id, inbox_id: inbox.id)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
Rails.logger.error("TikTok Channel creation Error: #{error.message}")
|
||||
ChatwootExceptionTracker.new(error).capture_exception
|
||||
|
||||
redirect_to_error_page(error_type: error.class.name, code: 500, error_message: error.message)
|
||||
end
|
||||
|
||||
# Handles the case when a user denies permissions or cancels the authorization flow
|
||||
def handle_authorization_error
|
||||
redirect_to_error_page(
|
||||
error_type: params[:error] || 'access_denied',
|
||||
code: params[:error_code],
|
||||
error_message: params[:error_description] || 'User cancelled the Authorization'
|
||||
)
|
||||
end
|
||||
|
||||
# Handles the case when a user partially accepted the required scopes
|
||||
def handle_ungranted_scopes_error
|
||||
redirect_to_error_page(
|
||||
error_type: 'ungranted_scopes',
|
||||
code: 400,
|
||||
error_message: 'User did not grant all the required scopes'
|
||||
)
|
||||
end
|
||||
|
||||
# Centralized method to redirect to error page with appropriate parameters
|
||||
# This ensures consistent error handling across different error scenarios
|
||||
# Frontend will handle the error page based on the error_type
|
||||
def redirect_to_error_page(error_type:, code:, error_message:)
|
||||
redirect_to app_new_tiktok_inbox_url(
|
||||
account_id: account_id,
|
||||
error_type: error_type,
|
||||
code: code,
|
||||
error_message: error_message
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
business_details = tiktok_client.business_account_details
|
||||
channel_tiktok = find_channel
|
||||
channel_exists = channel_tiktok.present?
|
||||
|
||||
if channel_tiktok
|
||||
update_channel(channel_tiktok, business_details)
|
||||
else
|
||||
channel_tiktok = create_channel_with_inbox(business_details)
|
||||
end
|
||||
|
||||
# reauthorized will also update cache keys for the associated inbox
|
||||
channel_tiktok.reauthorized!
|
||||
|
||||
set_avatar(channel_tiktok.inbox, business_details[:profile_image]) if business_details[:profile_image].present?
|
||||
|
||||
[channel_tiktok.inbox, channel_exists]
|
||||
end
|
||||
|
||||
def create_channel_with_inbox(business_details)
|
||||
ActiveRecord::Base.transaction do
|
||||
channel_tiktok = Channel::Tiktok.create!(
|
||||
account: account,
|
||||
business_id: short_term_access_token[:business_id],
|
||||
access_token: short_term_access_token[:access_token],
|
||||
refresh_token: short_term_access_token[:refresh_token],
|
||||
expires_at: short_term_access_token[:expires_at],
|
||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||
)
|
||||
|
||||
account.inboxes.create!(
|
||||
account: account,
|
||||
channel: channel_tiktok,
|
||||
name: business_details[:display_name].presence || business_details[:username]
|
||||
)
|
||||
|
||||
channel_tiktok
|
||||
end
|
||||
end
|
||||
|
||||
def find_channel
|
||||
Channel::Tiktok.find_by(business_id: short_term_access_token[:business_id], account: account)
|
||||
end
|
||||
|
||||
def update_channel(channel_tiktok, business_details)
|
||||
channel_tiktok.update!(
|
||||
access_token: short_term_access_token[:access_token],
|
||||
refresh_token: short_term_access_token[:refresh_token],
|
||||
expires_at: short_term_access_token[:expires_at],
|
||||
refresh_token_expires_at: short_term_access_token[:refresh_token_expires_at]
|
||||
)
|
||||
|
||||
channel_tiktok.inbox.update!(name: business_details[:display_name].presence || business_details[:username])
|
||||
end
|
||||
|
||||
def set_avatar(inbox, avatar_url)
|
||||
Avatar::AvatarFromUrlJob.perform_later(inbox, avatar_url)
|
||||
end
|
||||
|
||||
def account_id
|
||||
@account_id ||= verify_tiktok_token(params[:state])
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
def short_term_access_token
|
||||
@short_term_access_token ||= Tiktok::AuthClient.obtain_short_term_access_token(params[:code])
|
||||
end
|
||||
|
||||
def tiktok_client
|
||||
@tiktok_client ||= Tiktok::Client.new(
|
||||
business_id: short_term_access_token[:business_id],
|
||||
access_token: short_term_access_token[:access_token]
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,53 +0,0 @@
|
||||
class Webhooks::TiktokController < ActionController::API
|
||||
before_action :verify_signature!
|
||||
|
||||
def events
|
||||
event = JSON.parse(request_payload)
|
||||
if echo_event?
|
||||
# Add delay to prevent race condition where echo arrives before send message API completes
|
||||
# This avoids duplicate messages when echo comes early during API processing
|
||||
::Webhooks::TiktokEventsJob.set(wait: 2.seconds).perform_later(event)
|
||||
else
|
||||
::Webhooks::TiktokEventsJob.perform_later(event)
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_payload
|
||||
@request_payload ||= request.body.read
|
||||
end
|
||||
|
||||
def verify_signature!
|
||||
signature_header = request.headers['Tiktok-Signature']
|
||||
client_secret = GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||
received_timestamp, received_signature = extract_signature_parts(signature_header)
|
||||
|
||||
return head :unauthorized unless client_secret && received_timestamp && received_signature
|
||||
|
||||
signature_payload = "#{received_timestamp}.#{request_payload}"
|
||||
computed_signature = OpenSSL::HMAC.hexdigest('SHA256', client_secret, signature_payload)
|
||||
|
||||
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(computed_signature, received_signature)
|
||||
|
||||
# Check timestamp delay (acceptable delay: 5 seconds)
|
||||
current_timestamp = Time.current.to_i
|
||||
delay = current_timestamp - received_timestamp
|
||||
|
||||
return head :unauthorized if delay > 5
|
||||
end
|
||||
|
||||
def extract_signature_parts(signature_header)
|
||||
return [nil, nil] if signature_header.blank?
|
||||
|
||||
keys = signature_header.split(',')
|
||||
signature_parts = keys.map { |part| part.split('=') }.to_h
|
||||
[signature_parts['t']&.to_i, signature_parts['s']]
|
||||
end
|
||||
|
||||
def echo_event?
|
||||
params[:event] == 'im_send_msg'
|
||||
end
|
||||
end
|
||||
@@ -46,13 +46,6 @@ module Api::V2::Accounts::ReportsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_conversations_report
|
||||
builder = V2::Reports::Conversations::MetricBuilder.new(Current.account, build_params(type: :account))
|
||||
summary = builder.summary
|
||||
|
||||
[generate_conversation_report_metrics(summary)]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_params(base_params)
|
||||
@@ -78,16 +71,4 @@ module Api::V2::Accounts::ReportsHelper
|
||||
report[:resolved_conversations_count]
|
||||
]
|
||||
end
|
||||
|
||||
def generate_conversation_report_metrics(summary)
|
||||
[
|
||||
summary[:conversations_count],
|
||||
summary[:incoming_messages_count],
|
||||
summary[:outgoing_messages_count],
|
||||
Reports::TimeFormatPresenter.new(summary[:avg_first_response_time]).format,
|
||||
Reports::TimeFormatPresenter.new(summary[:avg_resolution_time]).format,
|
||||
summary[:resolutions_count],
|
||||
Reports::TimeFormatPresenter.new(summary[:reply_time]).format
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
# No need to replicate the same values in two places
|
||||
|
||||
# ------- Premium Features ------- #
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-captain'
|
||||
config_key: 'captain'
|
||||
enterprise: true
|
||||
saml:
|
||||
name: 'SAML SSO'
|
||||
description: 'Configuration for controlling SAML Single Sign-On availability'
|
||||
@@ -41,12 +48,6 @@ help_center:
|
||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||
enabled: true
|
||||
icon: 'icon-book-2-line'
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
enabled: true
|
||||
icon: 'icon-captain'
|
||||
config_key: 'captain'
|
||||
|
||||
# ------- Communication Channels ------- #
|
||||
live_chat:
|
||||
@@ -77,12 +78,6 @@ instagram:
|
||||
enabled: true
|
||||
icon: 'icon-instagram'
|
||||
config_key: 'instagram'
|
||||
tiktok:
|
||||
name: 'TikTok'
|
||||
description: 'Stay connected with your customers on TikTok'
|
||||
enabled: true
|
||||
icon: 'icon-tiktok'
|
||||
config_key: 'tiktok'
|
||||
whatsapp:
|
||||
name: 'WhatsApp'
|
||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
module Tiktok::IntegrationHelper
|
||||
# Generates a signed JWT token for Tiktok integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_tiktok_token(account_id)
|
||||
return if client_secret.blank?
|
||||
|
||||
JWT.encode(token_payload(account_id), client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate TikTok token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
# Verifies and decodes a Tiktok JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_tiktok_token(token)
|
||||
return if token.blank? || client_secret.blank?
|
||||
|
||||
decode_token(token, client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client_secret
|
||||
@client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil)
|
||||
end
|
||||
|
||||
def token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
def decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
<div
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
|
||||
class="flex flex-col w-full h-screen min-h-0"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainPreferences extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/preferences', { accountScoped: true });
|
||||
}
|
||||
|
||||
get() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
|
||||
updatePreferences(data) {
|
||||
return axios.put(this.url, data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainPreferences();
|
||||
@@ -1,107 +0,0 @@
|
||||
/* 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,14 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class TiktokChannel extends ApiClient {
|
||||
constructor() {
|
||||
super('tiktok', { accountScoped: true });
|
||||
}
|
||||
|
||||
generateAuthorization(payload) {
|
||||
return axios.post(`${this.url}/authorization`, payload);
|
||||
}
|
||||
}
|
||||
|
||||
export default new TiktokChannel();
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Device } from '@twilio/voice-sdk';
|
||||
import VoiceAPI from './voiceAPIClient';
|
||||
|
||||
const createCallDisconnectedEvent = () => new CustomEvent('call:disconnected');
|
||||
|
||||
class TwilioVoiceClient extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.device = null;
|
||||
this.activeConnection = null;
|
||||
this.initialized = false;
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async initializeDevice(inboxId) {
|
||||
this.destroyDevice();
|
||||
|
||||
const response = await VoiceAPI.getToken(inboxId);
|
||||
const { token, account_id } = response || {};
|
||||
if (!token) throw new Error('Invalid token');
|
||||
|
||||
this.device = new Device(token, {
|
||||
allowIncomingWhileBusy: true,
|
||||
disableAudioContextSounds: true,
|
||||
appParams: { account_id },
|
||||
});
|
||||
|
||||
this.device.removeAllListeners();
|
||||
this.device.on('connect', conn => {
|
||||
this.activeConnection = conn;
|
||||
conn.on('disconnect', this.onDisconnect);
|
||||
});
|
||||
|
||||
this.device.on('disconnect', this.onDisconnect);
|
||||
|
||||
this.device.on('tokenWillExpire', async () => {
|
||||
const r = await VoiceAPI.getToken(this.inboxId);
|
||||
if (r?.token) this.device.updateToken(r.token);
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
this.inboxId = inboxId;
|
||||
|
||||
return this.device;
|
||||
}
|
||||
|
||||
get hasActiveConnection() {
|
||||
return !!this.activeConnection;
|
||||
}
|
||||
|
||||
endClientCall() {
|
||||
if (this.activeConnection) {
|
||||
this.activeConnection.disconnect();
|
||||
}
|
||||
this.activeConnection = null;
|
||||
if (this.device) {
|
||||
this.device.disconnectAll();
|
||||
}
|
||||
}
|
||||
|
||||
destroyDevice() {
|
||||
if (this.device) {
|
||||
this.device.destroy();
|
||||
}
|
||||
this.activeConnection = null;
|
||||
this.device = null;
|
||||
this.initialized = false;
|
||||
this.inboxId = null;
|
||||
}
|
||||
|
||||
async joinClientCall({ to, conversationId }) {
|
||||
if (!this.device || !this.initialized || !to) return null;
|
||||
if (this.activeConnection) return this.activeConnection;
|
||||
|
||||
const params = {
|
||||
To: to,
|
||||
is_agent: 'true',
|
||||
conversation_id: conversationId,
|
||||
};
|
||||
|
||||
const connection = await this.device.connect({ params });
|
||||
this.activeConnection = connection;
|
||||
|
||||
connection.on('disconnect', this.onDisconnect);
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
onDisconnect = () => {
|
||||
this.activeConnection = null;
|
||||
this.dispatchEvent(createCallDisconnectedEvent());
|
||||
};
|
||||
}
|
||||
|
||||
export default new TwilioVoiceClient();
|
||||
@@ -1,40 +0,0 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../../ApiClient';
|
||||
import ContactsAPI from '../../contacts';
|
||||
|
||||
class VoiceAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('voice', { accountScoped: true });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
initiateCall(contactId, inboxId) {
|
||||
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
|
||||
}
|
||||
|
||||
leaveConference(inboxId, conversationId) {
|
||||
return axios
|
||||
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
params: { conversation_id: conversationId },
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
joinConference({ conversationId, inboxId, callSid }) {
|
||||
return axios
|
||||
.post(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
|
||||
conversation_id: conversationId,
|
||||
call_sid: callSid,
|
||||
})
|
||||
.then(r => r.data);
|
||||
}
|
||||
|
||||
getToken(inboxId) {
|
||||
if (!inboxId) return Promise.reject(new Error('Inbox ID is required'));
|
||||
return axios
|
||||
.get(`${this.baseUrl()}/inboxes/${inboxId}/conference/token`)
|
||||
.then(r => r.data);
|
||||
}
|
||||
}
|
||||
|
||||
export default new VoiceAPI();
|
||||
@@ -32,16 +32,6 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
syncTemplates(inboxId) {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
createCSATTemplate(inboxId, template) {
|
||||
return axios.post(`${this.url}/${inboxId}/csat_template`, {
|
||||
template,
|
||||
});
|
||||
}
|
||||
|
||||
getCSATTemplateStatus(inboxId) {
|
||||
return axios.get(`${this.url}/${inboxId}/csat_template`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Inboxes();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/* 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();
|
||||
@@ -61,12 +61,6 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/conversations_summary`, {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||
return axios.get(`${this.url}/conversation_traffic`, {
|
||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||
|
||||
@@ -14,48 +14,38 @@ class SearchAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
contacts({ q, page = 1, since, until }) {
|
||||
contacts({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/contacts`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
conversations({ q, page = 1, since, until }) {
|
||||
conversations({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/conversations`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
messages({ q, page = 1, since, until, from, inboxId }) {
|
||||
messages({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/messages`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
from,
|
||||
inbox_id: inboxId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
articles({ q, page = 1, since, until }) {
|
||||
articles({ q, page = 1 }) {
|
||||
return axios.get(`${this.url}/articles`, {
|
||||
params: {
|
||||
q,
|
||||
page: page,
|
||||
since,
|
||||
until,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import searchAPI from '../search';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#SearchAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(searchAPI).toBeInstanceOf(ApiClient);
|
||||
expect(searchAPI).toHaveProperty('get');
|
||||
expect(searchAPI).toHaveProperty('contacts');
|
||||
expect(searchAPI).toHaveProperty('conversations');
|
||||
expect(searchAPI).toHaveProperty('messages');
|
||||
expect(searchAPI).toHaveProperty('articles');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
const originalAxios = window.axios;
|
||||
const axiosMock = {
|
||||
get: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('#get', () => {
|
||||
searchAPI.get({ q: 'test query' });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search', {
|
||||
params: { q: 'test query' },
|
||||
});
|
||||
});
|
||||
|
||||
it('#contacts', () => {
|
||||
searchAPI.contacts({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('#contacts with date filters', () => {
|
||||
searchAPI.contacts({
|
||||
q: 'test',
|
||||
page: 2,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/contacts', {
|
||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
||||
});
|
||||
});
|
||||
|
||||
it('#conversations', () => {
|
||||
searchAPI.conversations({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/search/conversations',
|
||||
{
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#conversations with date filters', () => {
|
||||
searchAPI.conversations({
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/search/conversations',
|
||||
{
|
||||
params: { q: 'test', page: 1, since: 1700000000, until: 1732000000 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#messages', () => {
|
||||
searchAPI.messages({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: undefined,
|
||||
until: undefined,
|
||||
from: undefined,
|
||||
inbox_id: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#messages with all filters', () => {
|
||||
searchAPI.messages({
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
from: 'contact:42',
|
||||
inboxId: 10,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/messages', {
|
||||
params: {
|
||||
q: 'test',
|
||||
page: 1,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
from: 'contact:42',
|
||||
inbox_id: 10,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('#articles', () => {
|
||||
searchAPI.articles({ q: 'test', page: 1 });
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
||||
params: { q: 'test', page: 1, since: undefined, until: undefined },
|
||||
});
|
||||
});
|
||||
|
||||
it('#articles with date filters', () => {
|
||||
searchAPI.articles({
|
||||
q: 'test',
|
||||
page: 2,
|
||||
since: 1700000000,
|
||||
until: 1732000000,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/search/articles', {
|
||||
params: { q: 'test', page: 2, since: 1700000000, until: 1732000000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import ApiClient from '../ApiClient';
|
||||
import tiktokClient from '../channel/tiktokClient';
|
||||
|
||||
describe('#TiktokClient', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(tiktokClient).toBeInstanceOf(ApiClient);
|
||||
expect(tiktokClient).toHaveProperty('generateAuthorization');
|
||||
});
|
||||
|
||||
describe('#generateAuthorization', () => {
|
||||
const originalAxios = window.axios;
|
||||
const originalPathname = window.location.pathname;
|
||||
const axiosMock = {
|
||||
post: vi.fn(() => Promise.resolve()),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.axios = axiosMock;
|
||||
window.history.pushState({}, '', '/app/accounts/1/settings');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
window.history.pushState({}, '', originalPathname);
|
||||
});
|
||||
|
||||
it('posts to the authorization endpoint', () => {
|
||||
tiktokClient.generateAuthorization({ state: 'test-state' });
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1/tiktok/authorization',
|
||||
{ state: 'test-state' }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,34 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#5A6165" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M67.1503 94.3623C62.7578 95.2124 58.2429 95.2124 53.8503 94.3623" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26.1371 66.6506C25.287 62.258 25.287 57.7432 26.1371 53.3506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M89.4798 79.6348C86.9625 83.3499 83.7542 86.5463 80.0298 89.0498" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M94.8621 53.3506C95.7121 57.7432 95.7121 62.258 94.8621 66.6506" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#35373C" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg width="495" height="384" viewBox="0 0 495 384" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="No Chat History">
|
||||
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M266.903 0C221.687 0 180.128 15.7196 147.313 42.0198H94.2771C87.3866 42.0198 81.749 47.6925 81.749 54.6257C81.749 61.559 87.3866 67.2317 94.2771 67.2317H121.268H129.178C136.068 67.2317 141.706 72.9043 141.706 79.8376C141.706 83.3 140.294 86.4599 138.023 88.7374C135.759 91.0233 132.619 92.4435 129.178 92.4435H112.303H102.986H57.5281C50.6376 92.4435 45 98.1162 45 105.049C45 111.983 50.6376 117.655 57.5281 117.655H90.071C80.2473 140.886 74.8059 166.446 74.8059 193.291C74.8059 203.031 75.5242 212.603 76.9023 221.957C77.4786 225.865 78.1718 229.739 78.982 233.571C102.618 229.932 127.691 227.008 153.866 224.873L155.837 226.68L157.692 224.571C189.137 222.108 222.144 220.797 256.187 220.797C328.516 220.797 396.201 226.714 454.072 237C456.302 227.268 457.806 217.259 458.516 207.023C458.841 202.485 459 197.905 459 193.291C459 86.5356 372.999 0 266.903 0Z" fill="#43484C"/>
|
||||
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.2465 91.4282H12.6678C5.7005 91.4282 0 97.1882 0 104.228C0 111.268 5.7005 117.028 12.6678 117.028H20.2465C27.2137 117.028 32.9142 111.268 32.9142 104.228C32.9142 97.1882 27.2137 91.4282 20.2465 91.4282Z" fill="#43484C"/>
|
||||
<g id="Group 7">
|
||||
<mask id="mask0_201_141" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="78" y="219" width="417" height="165">
|
||||
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M495 219V384H286.5H78V219H495Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_201_141)">
|
||||
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M495 279.775C495 286.675 489.336 292.321 482.413 292.321H402.016C398.559 292.321 395.405 293.734 393.131 296.009C390.849 298.276 389.43 301.421 389.43 304.867C389.43 311.768 395.094 317.413 402.016 317.413H416.954C420.41 317.413 423.566 318.827 425.84 321.102C428.122 323.369 429.54 326.514 429.54 329.96C429.54 336.86 423.876 342.506 416.954 342.506H382.608C350.344 366.662 310.251 380.981 266.802 380.981C174.004 380.981 96.5026 315.699 78 228.695C101.747 225.073 126.937 222.162 153.235 220.038L155.215 221.836L157.079 219.736C188.671 217.286 221.833 215.981 256.036 215.981C328.703 215.981 396.705 221.869 454.847 232.107C452.02 244.302 448.026 256.045 442.991 267.228H482.413C485.879 267.228 489.017 268.642 491.3 270.917C493.582 273.184 495 276.328 495 279.775Z" fill="#34393D"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M459 206.12C458.285 216.665 456.771 226.975 454.525 237C396.235 226.404 328.06 220.31 255.208 220.31C220.919 220.31 187.673 221.66 156 224.196L177.965 198.615L212.134 221.028L258.388 208.19L337.959 220.31L372.503 208.19L381.252 220.31L402.126 204.736L421.632 222.707L452.396 198L459 206.12Z" fill="#55575E"/>
|
||||
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M344.905 216.502C353.565 203.915 358.526 188.987 358.397 173.012C358.043 128.578 318.46 92.8734 269.986 93.2602C221.512 93.6478 182.505 129.983 182.86 174.416C183.214 218.849 222.798 254.555 271.271 254.168C288.59 254.03 304.7 249.3 318.238 241.253L354.454 249.78L344.905 216.502Z" fill="#686B73"/>
|
||||
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.029 171.813C245.069 176.862 241.008 180.988 235.959 181.028C230.909 181.069 226.783 177.008 226.743 171.959C226.703 166.909 230.763 162.784 235.813 162.743C240.861 162.703 244.987 166.763 245.029 171.813Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M277.942 171.812C277.982 176.862 273.922 180.988 268.872 181.028C263.822 181.068 259.696 177.008 259.657 171.959C259.616 166.909 263.677 162.783 268.726 162.743C273.775 162.703 277.901 166.763 277.942 171.812Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M310.856 171.812C310.897 176.861 306.836 180.987 301.786 181.028C296.737 181.068 292.611 177.008 292.571 171.958C292.53 166.908 296.591 162.783 301.641 162.743C306.689 162.702 310.815 166.763 310.856 171.812Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M173.714 235.887C173.714 241.693 175.571 247.099 178.763 251.649L175.376 263.789L188.564 260.581C193.523 263.465 199.402 265.143 205.714 265.143C223.388 265.143 237.714 252.044 237.714 235.887C237.714 219.728 223.388 206.629 205.714 206.629C188.041 206.629 173.714 219.728 173.714 235.887Z" fill="url(#paint0_linear_201_141)"/>
|
||||
<g id="Frame 148">
|
||||
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M189 234.658C189 236.677 190.637 238.314 192.657 238.314C194.676 238.314 196.314 236.677 196.314 234.658C196.314 232.638 194.676 231 192.657 231C190.637 231 189 232.638 189 234.658Z" fill="#0D0D0D"/>
|
||||
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M202.314 234.658C202.314 236.677 203.951 238.314 205.971 238.314C207.99 238.314 209.628 236.677 209.628 234.658C209.628 232.638 207.99 231 205.971 231C203.951 231 202.314 232.638 202.314 234.658Z" fill="#0D0D0D"/>
|
||||
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M215.629 234.658C215.629 236.677 217.265 238.314 219.285 238.314C221.305 238.314 222.943 236.677 222.943 234.658C222.943 232.638 221.305 231 219.285 231C217.265 231 215.629 232.638 215.629 234.658Z" fill="#0D0D0D"/>
|
||||
</g>
|
||||
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M153.6 223.557C127.481 225.685 102.461 228.601 78.8755 232.228C78.0671 228.408 77.3754 224.546 76.8003 220.65L117.229 190.171L153.6 223.557Z" fill="#55575E"/>
|
||||
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M336.457 286.171C336.457 289.706 304.529 292.571 265.143 292.571C225.757 292.571 193.829 289.706 193.829 286.171C193.829 282.638 225.757 279.771 265.143 279.771C304.529 279.771 336.457 282.638 336.457 286.171Z" fill="#686B73"/>
|
||||
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M453.486 345.599C446.446 345.599 440.686 339.839 440.686 332.799C440.686 325.76 446.446 320 453.486 320C460.526 320 466.286 325.76 466.286 332.799C466.286 339.839 460.526 345.599 453.486 345.599Z" fill="#34393D"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_201_141" x1="205.714" y1="177.372" x2="147.434" y2="241.115" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9DC9FB"/>
|
||||
<stop offset="1" stop-color="#9DC9FB"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -1,11 +1,33 @@
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42.4834 68.6832C42.7774 69.425 42.8429 70.2378 42.6714 71.0172L40.5414 77.5971C40.4728 77.9308 40.4905 78.2765 40.5929 78.6014C40.6954 78.9263 40.8791 79.2197 41.1267 79.4536C41.3743 79.6876 41.6776 79.8545 42.0078 79.9384C42.3379 80.0223 42.6841 80.0205 43.0134 79.9331L49.8393 77.9371C50.5747 77.7912 51.3364 77.855 52.0373 78.1211C56.308 80.1155 61.146 80.5375 65.6975 79.3125C70.249 78.0876 74.2217 75.2944 76.9145 71.4259C79.6073 67.5573 80.8473 62.862 80.4157 58.1683C79.984 53.4747 77.9085 49.0843 74.5553 45.7717C71.202 42.4592 66.7866 40.4375 62.088 40.0632C57.3894 39.6889 52.7096 40.9861 48.8742 43.726C45.0388 46.4659 42.2944 50.4724 41.1252 55.0385C39.9559 59.6047 40.4369 64.4371 42.4834 68.6832Z" stroke="#B9BBC6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M53.8503 25.6371C58.2429 24.787 62.7578 24.787 67.1503 25.6371" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M67.1503 94.3621C62.7578 95.2121 58.2429 95.2121 53.8503 94.3621" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M80.1311 31.02C83.8462 33.5373 87.0426 36.7456 89.5461 40.47" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M26.1371 66.6503C25.287 62.2578 25.287 57.7429 26.1371 53.3503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M89.4798 79.635C86.9625 83.3502 83.7542 86.5465 80.0298 89.05" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M94.8621 53.3503C95.7121 57.7429 95.7121 62.2578 94.8621 66.6503" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M31.5249 40.3696C34.0422 36.6544 37.2504 33.4581 40.9749 30.9546" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M40.0717 91.9083L29.9007 94.8833C29.3241 95.0365 28.718 95.0397 28.1398 94.8927C27.5616 94.7457 27.0306 94.4534 26.5972 94.0434C26.1638 93.6335 25.8423 93.1196 25.6634 92.5505C25.4844 91.9814 25.4539 91.376 25.5747 90.7918L28.9522 80.3618" stroke="#E4E4E9" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<svg width="495" height="387" viewBox="0 0 495 387" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="No Chat History">
|
||||
<path id="Fill 1" fill-rule="evenodd" clip-rule="evenodd" d="M267.927 1.5C222.792 1.5 181.307 17.1457 148.551 43.3222H95.611C88.7329 43.3222 83.1054 48.9682 83.1054 55.8688C83.1054 62.7695 88.7329 68.4155 95.611 68.4155H122.554H130.449C137.327 68.4155 142.955 74.0615 142.955 80.9621C142.955 84.4083 141.546 87.5533 139.278 89.8201C137.019 92.0952 133.884 93.5088 130.449 93.5088H113.605H104.304H58.9279C52.0499 93.5088 46.4224 99.1548 46.4224 106.055C46.4224 112.956 52.0499 118.602 58.9279 118.602H91.4124C81.6064 141.723 76.1748 167.164 76.1748 193.882C76.1748 203.576 76.8918 213.103 78.2674 222.413C78.8426 226.303 79.5346 230.159 80.3433 233.973C103.937 230.351 128.965 227.44 155.093 225.316L157.061 227.114L158.912 225.014C190.301 222.564 223.249 221.259 257.23 221.259C329.429 221.259 396.993 227.147 454.76 237.385C456.986 227.699 458.487 217.737 459.196 207.549C459.521 203.033 459.679 198.474 459.679 193.882C459.679 87.6286 373.832 1.5 267.927 1.5Z" fill="#F1F2F7"/>
|
||||
<path id="Fill 3" fill-rule="evenodd" clip-rule="evenodd" d="M20.954 92.9282H13.3753C6.40802 92.9282 0.70752 98.6882 0.70752 105.728C0.70752 112.768 6.40802 118.528 13.3753 118.528H20.954C27.9213 118.528 33.6218 112.768 33.6218 105.728C33.6218 98.6882 27.9213 92.9282 20.954 92.9282Z" fill="#F1F2F7"/>
|
||||
<g id="Group 7">
|
||||
<mask id="mask0_156_62" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="80" y="221" width="415" height="165">
|
||||
<path id="Clip 6" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 221.536V385.5H287.188H80.083V221.536H494.293Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_156_62)">
|
||||
<path id="Fill 5" fill-rule="evenodd" clip-rule="evenodd" d="M494.293 284.929C494.293 291.786 488.667 297.396 481.79 297.396H401.932C398.498 297.396 395.364 298.801 393.106 301.062C390.838 303.314 389.429 306.439 389.429 309.864C389.429 316.721 395.055 322.331 401.932 322.331H416.769C420.202 322.331 423.337 323.736 425.596 325.997C427.863 328.249 429.271 331.374 429.271 334.799C429.271 341.656 423.645 347.266 416.769 347.266H382.653C350.604 371.271 310.78 385.5 267.622 385.5C175.444 385.5 98.4618 320.627 80.083 234.169C103.671 230.57 128.693 227.678 154.815 225.567L156.781 227.354L158.632 225.268C190.013 222.832 222.954 221.536 256.928 221.536C329.108 221.536 396.656 227.387 454.409 237.561C451.601 249.679 447.633 261.349 442.632 272.461H481.79C485.233 272.461 488.35 273.866 490.617 276.127C492.884 278.379 494.293 281.504 494.293 284.929Z" fill="#E8EBF2"/>
|
||||
</g>
|
||||
</g>
|
||||
<path id="Fill 8" fill-rule="evenodd" clip-rule="evenodd" d="M457.85 206.981C457.142 217.363 455.643 227.515 453.42 237.386C395.73 226.953 328.256 220.953 256.153 220.953C222.216 220.953 189.312 222.282 157.964 224.779L179.704 199.591L213.521 221.659L259.3 209.019L338.053 220.953L372.242 209.019L380.901 220.953L401.56 205.618L420.866 223.313L451.314 198.986L457.85 206.981Z" fill="#E0E2EE"/>
|
||||
<path id="Fill 10" fill-rule="evenodd" clip-rule="evenodd" d="M345.614 218.002C354.273 205.415 359.234 190.487 359.106 174.512C358.752 130.078 319.168 94.3734 270.694 94.7602C222.22 95.1478 183.213 131.483 183.568 175.916C183.923 220.349 223.506 256.055 271.979 255.668C289.298 255.53 305.408 250.8 318.946 242.753L355.162 251.28L345.614 218.002Z" fill="#D8DBEA"/>
|
||||
<path id="Fill 12" fill-rule="evenodd" clip-rule="evenodd" d="M245.736 173.313C245.776 178.362 241.716 182.488 236.667 182.528C231.616 182.569 227.49 178.508 227.451 173.459C227.411 168.409 231.471 164.284 236.52 164.243C241.569 164.203 245.695 168.263 245.736 173.313Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 14" fill-rule="evenodd" clip-rule="evenodd" d="M278.65 173.312C278.69 178.362 274.629 182.488 269.58 182.528C264.53 182.568 260.404 178.508 260.365 173.459C260.323 168.409 264.384 164.283 269.434 164.243C274.483 164.203 278.609 168.263 278.65 173.312Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 16" fill-rule="evenodd" clip-rule="evenodd" d="M311.565 173.312C311.605 178.361 307.544 182.487 302.495 182.528C297.445 182.568 293.319 178.508 293.28 173.458C293.239 168.408 297.299 164.283 302.349 164.243C307.398 164.202 311.524 168.263 311.565 173.312Z" fill="#BEC0D6"/>
|
||||
<path id="Fill 18" fill-rule="evenodd" clip-rule="evenodd" d="M157.965 224.585L156.192 226.414L154.308 224.848C155.521 224.753 156.744 224.666 157.965 224.585Z" fill="#F1F2F7"/>
|
||||
<path id="Fill 20" fill-rule="evenodd" clip-rule="evenodd" d="M174.422 237.387C174.422 243.193 176.279 248.599 179.47 253.149L176.083 265.289L189.272 262.081C194.23 264.965 200.11 266.643 206.422 266.643C224.095 266.643 238.422 253.544 238.422 237.387C238.422 221.228 224.095 208.129 206.422 208.129C188.748 208.129 174.422 221.228 174.422 237.387Z" fill="url(#paint0_linear_156_62)"/>
|
||||
<path id="Fill 22" fill-rule="evenodd" clip-rule="evenodd" d="M214.651 237.386C214.651 239.405 216.287 241.043 218.308 241.043C220.327 241.043 221.965 239.405 221.965 237.386C221.965 235.366 220.327 233.728 218.308 233.728C216.287 233.728 214.651 235.366 214.651 237.386Z" fill="white"/>
|
||||
<path id="Fill 24" fill-rule="evenodd" clip-rule="evenodd" d="M203.679 237.386C203.679 239.405 205.316 241.043 207.336 241.043C209.355 241.043 210.993 239.405 210.993 237.386C210.993 235.366 209.355 233.728 207.336 233.728C205.316 233.728 203.679 235.366 203.679 237.386Z" fill="white"/>
|
||||
<path id="Fill 26" fill-rule="evenodd" clip-rule="evenodd" d="M190.879 237.386C190.879 239.405 192.516 241.043 194.537 241.043C196.556 241.043 198.194 239.405 198.194 237.386C198.194 235.366 196.556 233.728 194.537 233.728C192.516 233.728 190.879 235.366 190.879 237.386Z" fill="white"/>
|
||||
<path id="Fill 28" fill-rule="evenodd" clip-rule="evenodd" d="M154.308 225.057C128.188 227.185 103.169 230.101 79.583 233.728C78.7746 229.908 78.0829 226.046 77.5078 222.15L117.936 191.671L154.308 225.057Z" fill="#E0E2EE"/>
|
||||
<path id="Fill 30" fill-rule="evenodd" clip-rule="evenodd" d="M337.164 287.671C337.164 291.206 305.235 294.071 265.85 294.071C226.464 294.071 194.536 291.206 194.536 287.671C194.536 284.138 226.464 281.271 265.85 281.271C305.235 281.271 337.164 284.138 337.164 287.671Z" fill="#D8DBEA"/>
|
||||
<path id="Fill 32" fill-rule="evenodd" clip-rule="evenodd" d="M454.193 347.099C447.153 347.099 441.393 341.339 441.393 334.299C441.393 327.26 447.153 321.5 454.193 321.5C461.233 321.5 466.993 327.26 466.993 334.299C466.993 341.339 461.233 347.099 454.193 347.099Z" fill="#E1E3EF"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_156_62" x1="206.422" y1="178.872" x2="148.142" y2="242.615" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9DC9FB"/>
|
||||
<stop offset="1" stop-color="#9DC9FB"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -94,52 +94,21 @@
|
||||
--gray-11: 100 100 100;
|
||||
--gray-12: 32 32 32;
|
||||
|
||||
--violet-1: 253 252 254;
|
||||
--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;
|
||||
--background-color: 253 253 253;
|
||||
--text-blue: 8 109 224;
|
||||
--border-container: 236 236 236;
|
||||
--border-strong: 226 227 231;
|
||||
--border-strong: 235 235 235;
|
||||
--border-weak: 234 234 234;
|
||||
--border-blue-strong: 18 61 117;
|
||||
--solid-1: 255 255 255;
|
||||
--solid-2: 255 255 255;
|
||||
--solid-3: 255 255 255;
|
||||
--solid-active: 255 255 255;
|
||||
--solid-amber: 255 228 181;
|
||||
--solid-amber: 252 232 193;
|
||||
--solid-blue: 218 236 255;
|
||||
--solid-blue-2: 251 253 255;
|
||||
--solid-iris: 230 231 255;
|
||||
--solid-purple: 230 231 255;
|
||||
--solid-red: 254 200 201;
|
||||
--solid-amber-button: 255 221 141;
|
||||
--card-color: 255 255 255;
|
||||
--overlay: 0, 0, 0, 0.12;
|
||||
--overlay-avatar: 255, 255, 255, 0.67;
|
||||
--button-color: 255 255 255;
|
||||
--button-hover-color: 255, 255, 255, 0.2;
|
||||
--label-background: 247 247 247;
|
||||
--label-border: 0, 0, 0, 0.04;
|
||||
|
||||
--alpha-1: 215, 215, 215, 0.22;
|
||||
--alpha-2: 196, 197, 198, 0.22;
|
||||
--alpha-1: 67, 67, 67, 0.06;
|
||||
--alpha-2: 201, 202, 207, 0.15;
|
||||
--alpha-3: 255, 255, 255, 0.96;
|
||||
--black-alpha-1: 0, 0, 0, 0.12;
|
||||
--black-alpha-2: 0, 0, 0, 0.04;
|
||||
@@ -240,56 +209,25 @@
|
||||
--gray-11: 180 180 180;
|
||||
--gray-12: 238 238 238;
|
||||
|
||||
--violet-1: 20 17 31;
|
||||
--violet-2: 27 21 37;
|
||||
--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;
|
||||
--background-color: 18 18 19;
|
||||
--border-strong: 52 52 52;
|
||||
--border-weak: 38 38 42;
|
||||
--solid-1: 23 23 26;
|
||||
--solid-2: 29 30 36;
|
||||
--solid-3: 44 45 54;
|
||||
--solid-active: 53 57 66;
|
||||
--solid-amber: 56 50 41;
|
||||
--solid-blue: 15 57 102;
|
||||
--solid-blue-2: 26 29 35;
|
||||
--solid-amber: 42 37 30;
|
||||
--solid-blue: 16 49 91;
|
||||
--solid-iris: 38 42 101;
|
||||
--solid-purple: 51 51 107;
|
||||
--solid-red: 90 33 34;
|
||||
--solid-amber-button: 255 221 141;
|
||||
--card-color: 28 30 34;
|
||||
--overlay: 0, 0, 0, 0.4;
|
||||
--overlay-avatar: 0, 0, 0, 0.05;
|
||||
--button-color: 42 43 51;
|
||||
--button-hover-color: 0, 0, 0, 0.15;
|
||||
--label-background: 36 38 45;
|
||||
--label-border: 255, 255, 255, 0.03;
|
||||
--text-blue: 126 182 255;
|
||||
|
||||
--alpha-1: 35, 36, 42, 0.8;
|
||||
--alpha-2: 147, 153, 176, 0.12;
|
||||
--alpha-3: 33, 34, 38, 0.95;
|
||||
--alpha-1: 36, 36, 36, 0.8;
|
||||
--alpha-2: 139, 147, 182, 0.15;
|
||||
--alpha-3: 36, 38, 45, 0.9;
|
||||
--black-alpha-1: 0, 0, 0, 0.3;
|
||||
--black-alpha-2: 0, 0, 0, 0.2;
|
||||
--border-blue: 39, 129, 246, 0.5;
|
||||
--border-container: 255, 255, 255, 0;
|
||||
--border-container: 236, 236, 236, 0;
|
||||
--white-alpha: 255, 255, 255, 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ body {
|
||||
}
|
||||
|
||||
.custom-dashed-border {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23E2E3E7' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.dark .custom-dashed-border {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='1' y='1' width='calc(100%25 - 2px)' height='calc(100%25 - 2px)' fill='none' rx='12' ry='12' stroke='%23343434' stroke-width='1.5' stroke-dasharray='6 4' stroke-linecap='butt'/%3E%3C/svg%3E");
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@@ -66,84 +66,4 @@ body {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* TYPOGRAPHY UTILITIES
|
||||
* ============================================================================
|
||||
*
|
||||
* | Class | Use Case |
|
||||
* |--------------------|----------------------------------------------------|
|
||||
* | .text-body-main | <p>, <span>, general body text |
|
||||
* | .text-body-para | <p> for paragraphs, larger text blocks |
|
||||
* | .text-heading-1 | <h1>, page titles, panel headers |
|
||||
* | .text-heading-2 | <h2>, section headings, card titles |
|
||||
* | .text-heading-3 | <h3>, card headings, breadcrumbs, subsections |
|
||||
* | .text-label | <label>, form labels, field names |
|
||||
* | .text-label-small | <small>, footnotes, tags, badges, captions |
|
||||
* | .text-button | <button>, standard button text |
|
||||
* | .text-button-small | <button>, small/compact button text |
|
||||
*/
|
||||
|
||||
/* body-text-main: Main text style for general body text */
|
||||
.text-body-main {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* body-text-paragraph: For paragraphs or larger blocks of text */
|
||||
.text-body-para {
|
||||
@apply font-inter text-sm font-420;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.21px;
|
||||
}
|
||||
|
||||
/* heading-1: Large heading for pages and panels */
|
||||
.text-heading-1 {
|
||||
@apply font-inter text-lg font-520;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-2: Secondary heading for sections */
|
||||
.text-heading-2 {
|
||||
@apply font-inter text-base font-medium;
|
||||
line-height: 24px; /* 133.333% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* heading-3: For card headings, breadcrumbs, subsections */
|
||||
.text-heading-3 {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.27px;
|
||||
}
|
||||
|
||||
/* label: Standard label text for form fields */
|
||||
.text-label {
|
||||
@apply font-inter text-sm font-medium;
|
||||
line-height: 21px; /* 150% */
|
||||
}
|
||||
|
||||
/* label-small: Smallest font for labels, footnotes, tags */
|
||||
.text-label-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 16px; /* 133.333% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
|
||||
/* button-text: Text for standard size buttons */
|
||||
.text-button {
|
||||
@apply font-inter text-sm font-460;
|
||||
line-height: 21px; /* 150% */
|
||||
letter-spacing: -0.28px;
|
||||
}
|
||||
|
||||
/* button-text-small: Text for smaller buttons */
|
||||
.text-button-small {
|
||||
@apply font-inter text-xs font-440;
|
||||
line-height: 18px; /* 150% */
|
||||
letter-spacing: -0.24px;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
|
||||
@@ -103,7 +103,7 @@ onMounted(() => {
|
||||
:key="tag.id"
|
||||
:label="tag"
|
||||
:is-hovered="hoveredLabel === tag.id"
|
||||
class="h-8 ltr:!pr-1 rtl:!pl-1"
|
||||
class="h-8"
|
||||
@remove="onClickRemoveTag"
|
||||
@hover="hoveredLabel = tag.id"
|
||||
/>
|
||||
|
||||
@@ -77,7 +77,7 @@ const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
return getInboxIconByType(type, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+2
-2
@@ -47,8 +47,8 @@ const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
|
||||
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
|
||||
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.FROM') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-4" />
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ inboxName }}
|
||||
</span>
|
||||
|
||||
@@ -20,13 +20,11 @@ const handleButtonClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1">
|
||||
<header
|
||||
class="sticky top-0 z-10 px-6 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
|
||||
>
|
||||
<div class="w-full max-w-5xl mx-auto">
|
||||
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
|
||||
<header class="sticky top-0 z-10 px-6 lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto">
|
||||
<div class="flex items-center justify-between w-full h-20 gap-2">
|
||||
<span class="text-heading-1 text-n-slate-12">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div
|
||||
@@ -45,8 +43,8 @@ const handleButtonClick = () => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
|
||||
<div class="w-full max-w-[60rem] mx-auto py-4">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -19,13 +19,13 @@ const handleClick = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full outline-1 outline outline-n-container group/cardLayout rounded-xl bg-n-solid-2"
|
||||
class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-2 py-4"
|
||||
class="flex w-full gap-3 py-5"
|
||||
:class="[
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
|
||||
selectable ? 'px-10 py-6' : 'px-4',
|
||||
selectable ? 'px-10 py-6' : 'px-6',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedCompanyName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['change']);
|
||||
const companyId = defineModel({
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const [showCompanyDropdown, toggleCompanyDropdown] = useToggle();
|
||||
|
||||
const searchQuery = ref('');
|
||||
const isSearching = ref(false);
|
||||
|
||||
const companies = computed(() => companiesStore.getCompaniesList || []);
|
||||
const isLoading = computed(() => companiesStore.uiFlags.fetchingList);
|
||||
|
||||
const clearOption = computed(() => ({
|
||||
label: t('DROPDOWN_MENU.CLEAR_SELECTION'),
|
||||
value: 'clear',
|
||||
action: 'clear',
|
||||
}));
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(company => ({
|
||||
label: company.name,
|
||||
value: company.id,
|
||||
action: 'select',
|
||||
isSelected: companyId.value === company.id,
|
||||
}))
|
||||
);
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items = [];
|
||||
// Show clear option if a company is selected (either by ID or by name prop)
|
||||
if (companyId.value || props.selectedCompanyName) {
|
||||
items.push(clearOption.value);
|
||||
}
|
||||
// Add company options after clear option
|
||||
items.push(...companyOptions.value);
|
||||
return items;
|
||||
});
|
||||
|
||||
const selectedCompany = computed(() => {
|
||||
return companies.value.find(company => company.id === companyId.value);
|
||||
});
|
||||
|
||||
const buttonLabel = computed(() => {
|
||||
if (selectedCompany.value) {
|
||||
return selectedCompany.value.name;
|
||||
}
|
||||
if (props.selectedCompanyName) {
|
||||
return props.selectedCompanyName;
|
||||
}
|
||||
return props.placeholder;
|
||||
});
|
||||
|
||||
const fetchCompanies = async (search = '') => {
|
||||
if (!search) return;
|
||||
isSearching.value = true;
|
||||
try {
|
||||
await companiesStore.search({
|
||||
search,
|
||||
page: 1,
|
||||
sort: 'name',
|
||||
});
|
||||
} finally {
|
||||
isSearching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(query => {
|
||||
searchQuery.value = query;
|
||||
fetchCompanies(query);
|
||||
}, 300);
|
||||
|
||||
const handleAction = ({ value, action }) => {
|
||||
if (action === 'clear') {
|
||||
companyId.value = null;
|
||||
emit('change', null);
|
||||
toggleCompanyDropdown(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (companyId.value === value) {
|
||||
companyId.value = null;
|
||||
emit('change', null);
|
||||
} else {
|
||||
companyId.value = value;
|
||||
const company = companies.value.find(c => c.id === value);
|
||||
emit('change', company);
|
||||
}
|
||||
toggleCompanyDropdown(false);
|
||||
};
|
||||
|
||||
const handleSearch = query => {
|
||||
debouncedSearch(query);
|
||||
};
|
||||
|
||||
const handleClickOutside = () => {
|
||||
toggleCompanyDropdown(false);
|
||||
};
|
||||
|
||||
watch(showCompanyDropdown, isOpen => {
|
||||
if (!isOpen) {
|
||||
searchQuery.value = '';
|
||||
companiesStore.clearRecords();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-click-outside="handleClickOutside" class="relative w-full min-w-0">
|
||||
<Button
|
||||
slate
|
||||
sm
|
||||
:label="buttonLabel"
|
||||
no-animation
|
||||
type="button"
|
||||
:variant="showCompanyDropdown ? 'faded' : 'solid'"
|
||||
icon="i-lucide-briefcase-business"
|
||||
justify="start"
|
||||
class="w-full -outline-offset-1"
|
||||
@click="toggleCompanyDropdown()"
|
||||
/>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-100 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-75 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<DropdownMenu
|
||||
v-if="showCompanyDropdown"
|
||||
:menu-items="menuItems"
|
||||
show-search
|
||||
disable-local-filtering
|
||||
:search-placeholder="t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')"
|
||||
:empty-state-message="t('COMPANIES.DROPDOWN_MENU.EMPTY_STATE')"
|
||||
:is-loading="isLoading"
|
||||
:is-searching="isSearching"
|
||||
class="max-h-60 overflow-y-auto absolute z-50 w-full mt-1 top-full"
|
||||
@action="handleAction"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
+3
-5
@@ -15,13 +15,11 @@ const emit = defineEmits(['search', 'update:sort']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="sticky top-0 z-10 after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none px-6"
|
||||
>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 gap-2 mx-auto max-w-5xl"
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-row flex-shrink-0 gap-2">
|
||||
|
||||
+1
-5
@@ -35,10 +35,6 @@ const sortMenus = [
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
|
||||
value: 'created_at',
|
||||
},
|
||||
{
|
||||
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
|
||||
value: 'contacts_count',
|
||||
},
|
||||
];
|
||||
|
||||
const orderingMenus = [
|
||||
@@ -91,7 +87,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
@@ -21,7 +21,7 @@ const updateCurrentPage = page => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface-1"
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<CompanyHeader
|
||||
@@ -32,18 +32,17 @@ const updateCurrentPage = page => {
|
||||
@search="emit('search', $event)"
|
||||
@update:sort="emit('update:sort', $event)"
|
||||
/>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-5xl py-4">
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="COMPANIES_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
:total-items="totalItems"
|
||||
:items-per-page="25"
|
||||
class="max-w-[67rem]"
|
||||
@update:current-page="updateCurrentPage"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, watch, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
|
||||
import LabelItem from 'dashboard/components-next/label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/label/AddLabel.vue';
|
||||
import LabelItem from 'dashboard/components-next/Label/LabelItem.vue';
|
||||
import AddLabel from 'dashboard/components-next/Label/AddLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
wrapperRef: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
@@ -17,6 +14,7 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const showDropdown = ref(false);
|
||||
|
||||
@@ -26,14 +24,12 @@ const showDropdown = ref(false);
|
||||
const hoveredLabel = ref(null);
|
||||
|
||||
const allLabels = useMapGetter('labels/getLabels');
|
||||
const getContactById = useMapGetter('contacts/getContactById');
|
||||
const getContactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
const contactLabels = useMapGetter('contactLabels/getContactLabels');
|
||||
|
||||
const savedLabels = computed(() => {
|
||||
const contactLabelsList = getContactLabels.value(props.contactId) || [];
|
||||
|
||||
const availableContactLabels = contactLabels.value(props.contactId);
|
||||
return allLabels.value.filter(({ title }) =>
|
||||
contactLabelsList.includes(title)
|
||||
availableContactLabels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -51,6 +47,13 @@ const labelMenuItems = computed(() => {
|
||||
.toSorted((a, b) => Number(a.isSelected) - Number(b.isSelected));
|
||||
});
|
||||
|
||||
const fetchLabels = async contactId => {
|
||||
if (!contactId) {
|
||||
return;
|
||||
}
|
||||
store.dispatch('contactLabels/get', contactId);
|
||||
};
|
||||
|
||||
const handleLabelAction = async ({ value }) => {
|
||||
try {
|
||||
// Get current label titles
|
||||
@@ -77,11 +80,6 @@ const handleLabelAction = async ({ value }) => {
|
||||
labels: updatedLabels,
|
||||
});
|
||||
|
||||
store.dispatch('contacts/updateContactLabels', {
|
||||
contactId: props.contactId,
|
||||
labels: updatedLabels,
|
||||
});
|
||||
|
||||
showDropdown.value = false;
|
||||
} catch (error) {
|
||||
// error
|
||||
@@ -92,19 +90,19 @@ const handleRemoveLabel = label => {
|
||||
return handleLabelAction({ value: label.id });
|
||||
};
|
||||
|
||||
// Sync contact labels from contact object to contactLabels store when contact loads
|
||||
watch(
|
||||
() => getContactById.value(props.contactId),
|
||||
contact => {
|
||||
if (contact?.labels) {
|
||||
store.dispatch('contactLabels/setContactLabel', {
|
||||
id: props.contactId,
|
||||
data: contact.labels,
|
||||
});
|
||||
() => props.contactId,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal !== oldVal) {
|
||||
fetchLabels(newVal);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
onMounted(() => {
|
||||
if (route.params.contactId) {
|
||||
fetchLabels(route.params.contactId);
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Reset hover state when mouse leaves the container
|
||||
@@ -127,13 +125,11 @@ const handleLabelHover = labelId => {
|
||||
:key="label.id"
|
||||
:label="label"
|
||||
:is-hovered="hoveredLabel === label.id"
|
||||
class="ltr:!pr-1 rtl:!pl-1"
|
||||
@remove="handleRemoveLabel"
|
||||
@hover="handleLabelHover(label.id)"
|
||||
/>
|
||||
<AddLabel
|
||||
:label-menu-items="labelMenuItems"
|
||||
:wrapper-ref="wrapperRef"
|
||||
@update-label="handleLabelAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const state = reactive({
|
||||
message: '',
|
||||
});
|
||||
|
||||
const onAdd = async content => {
|
||||
if (!content) return;
|
||||
try {
|
||||
await store.dispatch('contactNotes/create', {
|
||||
content,
|
||||
contactId: props.contactId,
|
||||
});
|
||||
state.message = '';
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_LAYOUT.CARD.ADD_NOTE.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'$mod+Enter': {
|
||||
action: () => onAdd(state.message),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-4 py-3">
|
||||
<span
|
||||
class="py-1 text-heading-3 text-n-slate-12 z-10 h-6 bg-n-surface-1 inline-flex items-center gap-2"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-file-plus-corner"
|
||||
class="size-4 text-n-slate-11 hidden lg:block"
|
||||
/>
|
||||
{{ t('CONTACTS_LAYOUT.CARD.ADD_NOTE.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-6 lg:px-6 max-w-lg w-full">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.CARD.ADD_NOTE.PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:!px-3 [&>div]:!pb-4 [&_.ProseMirror-woot-style]:min-h-6 [&_.ProseMirror-menubar]:!relative ltr:[&_.ProseMirror-menubar]:!-left-[3px] ltr:[&_.ProseMirror-menubar]:!right-[unset] rtl:[&_.ProseMirror-menubar]:!-right-[3px] rtl:[&_.ProseMirror-menubar]:!left-[unset] [&_.ProseMirror-menubar]:!w-[unset] [&_.ProseMirror-menubar]:!top-[unset] [&_.ProseMirror-menubar-spacer]:!hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
const props = defineProps({
|
||||
customAttributes: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const displayAttributes = computed(() => {
|
||||
const selectedKeys = uiSettings.value.contact_list_display_properties || [];
|
||||
if (!selectedKeys.length) return [];
|
||||
|
||||
return contactAttributes.value
|
||||
.filter(
|
||||
attr =>
|
||||
selectedKeys.includes(attr.attributeKey) &&
|
||||
attr.attributeKey in props.customAttributes
|
||||
)
|
||||
.map(attribute => ({
|
||||
attribute,
|
||||
value: props.customAttributes[attribute.attributeKey],
|
||||
}));
|
||||
});
|
||||
|
||||
const formatValue = (value, displayType) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
|
||||
switch (displayType) {
|
||||
case 'checkbox':
|
||||
return value
|
||||
? t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.YES')
|
||||
: t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO');
|
||||
case 'list':
|
||||
return Array.isArray(value) ? value.join(', ') : value;
|
||||
case 'date':
|
||||
return new Date(value).toLocaleDateString();
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template
|
||||
v-for="(item, index) in displayAttributes"
|
||||
:key="item.attribute.attributeKey"
|
||||
>
|
||||
<div
|
||||
v-if="index === 0"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: `${item.attribute.attributeDisplayName}: ${formatValue(item.value, item.attribute.attributeDisplayType)}`,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate"
|
||||
>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ item.attribute.attributeDisplayName }}{{ ':' }}
|
||||
</span>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ formatValue(item.value, item.attribute.attributeDisplayType) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="index < displayAttributes.length - 1"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
-370
@@ -1,370 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardContent from 'dashboard/components-next/Conversation/ConversationCard/CardContent.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import CardStatusIcon from 'dashboard/components-next/Conversation/ConversationCard/CardStatusIcon.vue';
|
||||
import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const contactNotes = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const contactConversations = useMapGetter(
|
||||
'contactConversations/getAllConversationsByContactId'
|
||||
);
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const agentsList = useMapGetter('agents/getAgents');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
acc[country.id] = country;
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const countryDetails = computed(() => {
|
||||
const attributes = props.contactData?.additionalAttributes || {};
|
||||
const { country, countryCode, city } = attributes;
|
||||
|
||||
if (!country && !countryCode) return null;
|
||||
|
||||
const activeCountry =
|
||||
countriesMap.value[country] || countriesMap.value[countryCode];
|
||||
if (!activeCountry) return null;
|
||||
|
||||
return {
|
||||
countryCode: activeCountry.id,
|
||||
city: city ? `${city},` : null,
|
||||
name: activeCountry.name,
|
||||
};
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
return [countryDetails.value.city, countryDetails.value.name]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const companyName = computed(() => {
|
||||
const attributes = props.contactData?.additionalAttributes || {};
|
||||
return attributes.companyName || '';
|
||||
});
|
||||
|
||||
const contactLabels = computed(() => {
|
||||
return props.contactData?.labels || [];
|
||||
});
|
||||
|
||||
const latestNote = computed(() => {
|
||||
if (!props.contactData?.id) return null;
|
||||
const notes = contactNotes.value(props.contactData.id);
|
||||
return notes && notes.length > 0 ? notes[0] : null;
|
||||
});
|
||||
|
||||
const latestConversation = computed(() => {
|
||||
if (!props.contactData?.id) return null;
|
||||
const conversations = contactConversations.value(props.contactData.id);
|
||||
return conversations && conversations.length > 0 ? conversations[0] : null;
|
||||
});
|
||||
|
||||
const lastMessageInConversation = computed(() => {
|
||||
if (!latestConversation.value) return null;
|
||||
return getLastMessage(latestConversation.value);
|
||||
});
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = latestConversation.value?.inboxId;
|
||||
return inboxId ? inboxGetter.value(inboxId) : {};
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
if (!latestConversation.value?.meta?.assignee?.id) return null;
|
||||
return agentsList.value?.find(
|
||||
agent => agent.id === latestConversation.value.meta.assignee.id
|
||||
);
|
||||
});
|
||||
|
||||
const getWrittenBy = ({ user } = {}) => {
|
||||
const currentUserId = currentUser.value?.id;
|
||||
return user?.id === currentUserId
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
|
||||
: user?.name || t('CONVERSATION.BOT');
|
||||
};
|
||||
|
||||
const formatTimestamp = timestamp => {
|
||||
if (!timestamp) return '';
|
||||
return dynamicTime(timestamp);
|
||||
};
|
||||
|
||||
const getConversationUrl = conversationId => {
|
||||
if (!conversationId || !currentAccountId.value) return '#';
|
||||
const url = `/app/accounts/${currentAccountId.value}/conversations/${conversationId}`;
|
||||
return router.push(url);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 ltr:pl-2 rtl:pr-2 lg:ltr:pl-9 lg:rtl:pr-9 lg:before:content-[''] lg:before:absolute lg:before:ltr:left-[2.688rem] lg:before:rtl:right-[2.688rem] lg:before:top-0 lg:before:w-px lg:before:h-4 lg:before:bg-n-weak"
|
||||
>
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<Icon icon="i-woot-overview" class="size-4 text-n-slate-11" />
|
||||
<h3 class="text-n-slate-11 text-body-main">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.TITLE') }}
|
||||
</h3>
|
||||
</div>
|
||||
<!-- Section 1: Basic Information -->
|
||||
<div
|
||||
class="hidden lg:flex rounded-xl bg-n-card w-auto outline outline-1 outline-n-container -outline-offset-1 p-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2 w-full">
|
||||
<div class="flex items-center gap-1">
|
||||
<Icon icon="i-lucide-circle-user" class="size-4 text-n-slate-11" />
|
||||
<span class="text-label-small text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.BASIC_INFO') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contactData?.email"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<!-- Email -->
|
||||
<div v-if="contactData?.email" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-woot-mail"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ contactData.email }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contactData?.phoneNumber"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<!-- Phone Number -->
|
||||
<div v-if="contactData?.phoneNumber" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-phone"
|
||||
class="size-3.5 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ contactData.phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="companyName" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<!-- Company Name -->
|
||||
<div v-if="companyName" class="flex items-center gap-1">
|
||||
<Icon
|
||||
icon="i-lucide-briefcase-business"
|
||||
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ companyName }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="countryDetails" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<!-- Location (Country & City) -->
|
||||
<div v-if="countryDetails" class="flex items-center gap-1">
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="contactLabels.length > 0"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<!-- Labels -->
|
||||
|
||||
<ContactLabels
|
||||
v-if="contactLabels.length > 0"
|
||||
:labels="contactLabels"
|
||||
disable-toggle
|
||||
class="my-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Latest Contact Note -->
|
||||
<div
|
||||
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Icon icon="i-lucide-notebook-pen" class="size-3.5 text-n-slate-11" />
|
||||
<span class="text-label-small text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_NOTE') }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="latestNote">
|
||||
<div class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<span class="text-label-small text-n-slate-11">
|
||||
{{ formatTimestamp(latestNote.createdAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="latestNote" class="flex items-center gap-1.5">
|
||||
<Avatar
|
||||
v-tooltip.left="{
|
||||
content: getWrittenBy(latestNote),
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="latestNote?.user?.name || 'Bot'"
|
||||
:src="
|
||||
latestNote?.user?.name
|
||||
? latestNote?.user?.thumbnail
|
||||
: '/assets/images/chatwoot_bot.png'
|
||||
"
|
||||
:size="14"
|
||||
rounded-full
|
||||
/>
|
||||
<p class="text-body-main text-n-slate-12 m-0 line-clamp-1">
|
||||
{{ latestNote.content }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-body-para text-n-slate-10 m-0 italic">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.NO_NOTES') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Latest Previous Conversation -->
|
||||
<div
|
||||
v-if="latestConversation"
|
||||
class="flex flex-col rounded-xl bg-n-card outline outline-1 outline-n-container -outline-offset-1 p-3 gap-1.6 w-full gap-1.5 cursor-pointer"
|
||||
@click="getConversationUrl(latestConversation.id)"
|
||||
>
|
||||
<div
|
||||
class="flex justify-between gap-2 w-full min-w-0"
|
||||
:class="
|
||||
latestConversation?.labels?.length > 0
|
||||
? 'sm:flex-row flex-col items-start sm:items-center '
|
||||
: 'flex-row items-center'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2 py-1 min-w-0 flex-shrink">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Icon
|
||||
icon="i-lucide-message-circle"
|
||||
class="size-3.5 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-label-small text-n-slate-11 m-0 truncate">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.QUICK_OVERVIEW.LATEST_CONVERSATION') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="inbox?.id" class="h-3 w-px bg-n-strong rounded-lg" />
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<InboxName
|
||||
v-if="inbox?.id"
|
||||
:inbox="inbox"
|
||||
class="gap-1 [&>span]:text-n-slate-11"
|
||||
/>
|
||||
<div
|
||||
v-if="latestConversation?.id"
|
||||
class="flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-label-small truncate text-n-slate-11">
|
||||
{{ latestConversation.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="latestConversation?.createdAt"
|
||||
class="h-3 w-px bg-n-strong rounded-lg"
|
||||
/>
|
||||
<span
|
||||
v-if="latestConversation?.updatedAt"
|
||||
class="text-label-small text-n-slate-11 truncate min-w-0"
|
||||
>
|
||||
{{ formatTimestamp(latestConversation.updatedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<ContactLabels
|
||||
v-if="latestConversation?.labels?.length > 0"
|
||||
:labels="latestConversation.labels"
|
||||
class="flex-1 min-w-0 sm:justify-end w-full sm:w-auto"
|
||||
disable-toggle
|
||||
>
|
||||
<template #before>
|
||||
<div data-before-slot class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon
|
||||
v-if="latestConversation.priority"
|
||||
:priority="latestConversation.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="assignee?.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon
|
||||
v-if="latestConversation.status"
|
||||
:status="latestConversation.status"
|
||||
/>
|
||||
<div class="h-3 w-px bg-n-weak rounded-lg mx-1" />
|
||||
</div>
|
||||
</template>
|
||||
</ContactLabels>
|
||||
<div v-else class="flex items-center justify-end gap-1.5 flex-1">
|
||||
<CardPriorityIcon
|
||||
v-if="latestConversation.priority"
|
||||
:priority="latestConversation.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="assignee?.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon
|
||||
v-if="latestConversation.status"
|
||||
:status="latestConversation.status"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent
|
||||
v-if="lastMessageInConversation"
|
||||
:last-message="lastMessageInConversation"
|
||||
:unread-count="lastMessageInConversation?.conversation?.unreadCount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showDeleteSection, toggleDeleteSection] = useToggle();
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
|
||||
const openConfirmDeleteContactDialog = () => {
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
@@ -1,46 +1,41 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
|
||||
import ContactCardOverview from 'dashboard/components-next/Contacts/ContactsCard/ContactCardOverview.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import ContactAttributeDisplay from './ContactAttributeDisplay.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: Number, required: true },
|
||||
name: { type: String, default: '' },
|
||||
email: { type: String, default: '' },
|
||||
labels: { type: Array, default: () => [] },
|
||||
additionalAttributes: { type: Object, default: () => ({}) },
|
||||
customAttributes: { type: Object, default: () => ({}) },
|
||||
phoneNumber: { type: String, default: '' },
|
||||
thumbnail: { type: String, default: '' },
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'updateContact',
|
||||
'showContact',
|
||||
'select',
|
||||
'sendMessage',
|
||||
'deleteContact',
|
||||
'avatarHover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const isPrefetching = ref(false);
|
||||
const hasPrefetched = ref(false);
|
||||
const contactsFormRef = ref(null);
|
||||
|
||||
const getInitialContactData = () => ({
|
||||
id: props.id,
|
||||
@@ -48,11 +43,12 @@ const getInitialContactData = () => ({
|
||||
email: props.email,
|
||||
phoneNumber: props.phoneNumber,
|
||||
additionalAttributes: props.additionalAttributes,
|
||||
labels: props.labels,
|
||||
});
|
||||
|
||||
const contactData = ref(getInitialContactData());
|
||||
|
||||
const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const countriesMap = computed(() => {
|
||||
return countries.reduce((acc, country) => {
|
||||
acc[country.code] = country;
|
||||
@@ -79,11 +75,6 @@ const countryDetails = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const companyName = computed(() => {
|
||||
const attributes = props.additionalAttributes || {};
|
||||
return attributes.companyName || '';
|
||||
});
|
||||
|
||||
const formattedLocation = computed(() => {
|
||||
if (!countryDetails.value) return '';
|
||||
|
||||
@@ -92,6 +83,14 @@ const formattedLocation = computed(() => {
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
const handleFormUpdate = updatedData => {
|
||||
Object.assign(contactData.value, updatedData);
|
||||
};
|
||||
|
||||
const handleUpdateContact = () => {
|
||||
emit('updateContact', contactData.value);
|
||||
};
|
||||
|
||||
const onClickExpand = () => {
|
||||
emit('toggle');
|
||||
contactData.value = getInitialContactData();
|
||||
@@ -99,275 +98,147 @@ const onClickExpand = () => {
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
|
||||
const onClickOpenSendMessage = () => emit('sendMessage', props.id);
|
||||
|
||||
const onClickDeleteContact = () => {
|
||||
emit('deleteContact', props.id);
|
||||
};
|
||||
|
||||
const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
|
||||
const prefetchContactData = async () => {
|
||||
if (hasPrefetched.value || isPrefetching.value || !props.id) return;
|
||||
|
||||
isPrefetching.value = true;
|
||||
try {
|
||||
await Promise.all([
|
||||
store.dispatch('contactNotes/get', { contactId: props.id }),
|
||||
store.dispatch('contactConversations/get', props.id),
|
||||
]);
|
||||
hasPrefetched.value = true;
|
||||
} catch (error) {
|
||||
// error
|
||||
} finally {
|
||||
isPrefetching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandHover = isHovered => {
|
||||
if (isHovered && !props.isExpanded) {
|
||||
prefetchContactData();
|
||||
}
|
||||
const handleAvatarHover = isHovered => {
|
||||
emit('avatarHover', isHovered);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="flex flex-col gap-2 p-2 lg:grid lg:gap-4 lg:items-center lg:rounded-lg lg:transition-all lg:duration-200 lg:grid-cols-[minmax(30%,1fr)_minmax(50%,1.5fr)_1fr]"
|
||||
:class="{ 'border-b border-n-weak lg:border-none': isExpanded }"
|
||||
<CardLayout
|
||||
:key="id"
|
||||
layout="row"
|
||||
:class="{
|
||||
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-3 lg:gap-2"
|
||||
:class="{ 'lg:col-span-3': isExpanded }"
|
||||
>
|
||||
<div class="flex-shrink-0 size-5 flex items-center justify-center">
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div
|
||||
class="relative hidden lg:block size-4 rounded-md hover:bg-n-alpha-2 flex-shrink-0"
|
||||
@mouseenter.passive="handleExpandHover(true)"
|
||||
@mouseleave.passive="handleExpandHover(false)"
|
||||
class="relative"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
link
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
class="flex-shrink-0 !size-8 absolute -inset-2"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="24"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize hover:cursor-pointer truncate text-n-slate-12 lg:max-w-40"
|
||||
@click="onClickViewDetails"
|
||||
>
|
||||
{{ name }}
|
||||
</h4>
|
||||
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="hidden lg:flex items-center gap-2 flex-shrink-0"
|
||||
>
|
||||
<div
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.SEND_MESSAGE')"
|
||||
icon="i-lucide-message-circle"
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline"
|
||||
@click="onClickOpenSendMessage"
|
||||
/>
|
||||
<div
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.VIEW_DETAILS')"
|
||||
icon="i-lucide-info"
|
||||
link
|
||||
sm
|
||||
class="hover:!no-underline"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
<Policy
|
||||
:permissions="['administrator']"
|
||||
class="flex items-center gap-2"
|
||||
rounded-full
|
||||
>
|
||||
<div
|
||||
class="w-px h-3 bg-n-strong rounded-md ltr:ml-1 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||
>
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.ACTIONS.DELETE_CONTACT')"
|
||||
icon="i-lucide-trash-2"
|
||||
link
|
||||
sm
|
||||
ruby
|
||||
class="hover:!no-underline"
|
||||
@click="onClickDeleteContact"
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="companyName"
|
||||
class="my-0 capitalize h-6 px-1 inline-flex items-center gap-1 rounded-md text-n-slate-12 max-w-40 min-w-0 bg-n-label-color outline outline-1 outline-n-label-border -outline-offset-1"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-briefcase-business"
|
||||
class="size-3.5 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="truncate text-label-small">{{ companyName }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative lg:hidden size-5 rounded-md hover:bg-n-alpha-2 ltr:ml-auto rtl:mr-auto flex-shrink-0"
|
||||
@mouseenter.passive="handleExpandHover(true)"
|
||||
@mouseleave.passive="handleExpandHover(false)"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
link
|
||||
slate
|
||||
sm
|
||||
no-animation
|
||||
class="flex-shrink-0 !size-8 absolute -inset-1.5"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-2 lg:gap-2 lg:min-w-0"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
v-if="email"
|
||||
v-tooltip.top="{
|
||||
content: email,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate lg:max-w-72"
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-mail"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ email }}
|
||||
</span>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="email && phoneNumber"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="phoneNumber"
|
||||
v-tooltip.top="{
|
||||
content: phoneNumber,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 truncate lg:max-w-72"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-phone"
|
||||
class="size-3 flex-shrink-0 text-n-slate-11"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="countryDetails"
|
||||
class="w-px h-3 bg-n-strong rounded-lg flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="countryDetails"
|
||||
v-tooltip.top="{
|
||||
content: formattedLocation,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-2 min-w-0 text-n-slate-11"
|
||||
>
|
||||
<Flag
|
||||
:country="countryDetails.countryCode"
|
||||
class="size-3.5 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-12 truncate">
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
</div>
|
||||
<ContactAttributeDisplay :custom-attributes="customAttributes" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:hidden">
|
||||
<div v-if="labels.length > 0" class="min-h-[1rem]">
|
||||
<ContactLabels :labels="labels" disable-toggle class="my-0" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
link
|
||||
xs
|
||||
class="w-fit hover:!no-underline"
|
||||
slate
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden lg:flex items-center justify-end gap-3 flex-shrink-0"
|
||||
:class="{ 'lg:hidden': isExpanded }"
|
||||
>
|
||||
<ContactLabels
|
||||
:labels="labels"
|
||||
disable-toggle
|
||||
class="my-0 flex-1 justify-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid [transition:grid-template-rows_300ms_ease-out]"
|
||||
:class="[
|
||||
isExpanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
|
||||
isExpanded ? '' : 'overflow-hidden',
|
||||
]"
|
||||
>
|
||||
<div class="min-h-0">
|
||||
<div
|
||||
class="relative flex flex-col pt-3 pb-4 lg:pt-[1.125rem] lg:pb-5 overflow-visible transition-opacity duration-[600ms] ease-out"
|
||||
:class="isExpanded ? 'opacity-100 delay-200' : 'opacity-0'"
|
||||
>
|
||||
<ContactCardOverview :contact-data="contactData" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -73,15 +73,15 @@ const closeMobileSidebar = () => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:3xl:ml-56 rtl:3xl:mr-56"
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-4xl">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-4 gap-2"
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
<Breadcrumb
|
||||
:items="breadcrumbItems"
|
||||
@@ -94,20 +94,23 @@ const closeMobileSidebar = () => {
|
||||
? $t('CONTACTS_LAYOUT.HEADER.BLOCK_CONTACT')
|
||||
: $t('CONTACTS_LAYOUT.HEADER.UNBLOCK_CONTACT')
|
||||
"
|
||||
size="sm"
|
||||
slate
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating"
|
||||
@click="toggleBlock"
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:contact-id="contactId"
|
||||
size="md"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<Button
|
||||
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
size="sm"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
@@ -117,7 +120,7 @@ const closeMobileSidebar = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full pt-2.5 pb-4 mx-auto max-w-4xl">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
@@ -126,7 +129,7 @@ const closeMobileSidebar = () => {
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-5 max-w-md ltr:border-l rtl:border-r border-n-weak bg-n-surface-2"
|
||||
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
@@ -38,13 +37,13 @@ const { t } = useI18n();
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-teal-11 outline outline-1 outline-n-weak"
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-teal-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_HELP_LABEL') }}
|
||||
</span>
|
||||
@@ -70,7 +69,7 @@ const { t } = useI18n();
|
||||
@search="query => emit('search', query)"
|
||||
/>
|
||||
</div>
|
||||
<div class="relative flex justify-center gap-2 mt-4 mb-3">
|
||||
<div class="relative flex justify-center gap-2 top-4">
|
||||
<div v-for="i in 3" :key="i" class="relative w-4 h-8">
|
||||
<div
|
||||
class="absolute w-0 h-0 border-l-[4px] border-r-[4px] border-b-[6px] border-l-transparent border-r-transparent border-n-strong ltr:translate-x-[4px] rtl:-translate-x-[4px] -translate-y-[4px]"
|
||||
@@ -80,13 +79,13 @@ const { t } = useI18n();
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between h-5 gap-2">
|
||||
<label class="text-sm text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT') }}
|
||||
</label>
|
||||
<span
|
||||
class="flex items-center justify-center px-1.5 h-6 font-420 text-xs rounded-md text-n-ruby-11 outline outline-1 outline-n-weak"
|
||||
class="flex items-center justify-center w-24 h-5 text-xs rounded-md text-n-ruby-11 bg-n-alpha-2"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PARENT_HELP_LABEL') }}
|
||||
</span>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useI18n } from 'vue-i18n';
|
||||
import { required, email } from '@vuelidate/validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { splitName } from '@chatwoot/utils';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import CountryDropdown from 'dashboard/components-next/Countries/CountryDropdown.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
|
||||
import CompaniesDropdown from 'dashboard/components-next/Companies/CompaniesDropdown.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contactData: {
|
||||
@@ -44,7 +44,6 @@ const SOCIAL_CONFIG = {
|
||||
LINKEDIN: 'i-ri-linkedin-box-fill',
|
||||
FACEBOOK: 'i-ri-facebook-circle-fill',
|
||||
INSTAGRAM: 'i-ri-instagram-line',
|
||||
TIKTOK: 'i-ri-tiktok-fill',
|
||||
TWITTER: 'i-ri-twitter-x-fill',
|
||||
GITHUB: 'i-ri-github-fill',
|
||||
};
|
||||
@@ -66,7 +65,6 @@ const defaultState = {
|
||||
facebook: '',
|
||||
github: '',
|
||||
instagram: '',
|
||||
tiktok: '',
|
||||
linkedin: '',
|
||||
twitter: '',
|
||||
},
|
||||
@@ -124,6 +122,10 @@ const prepareStateBasedOnProps = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const countryOptions = computed(() =>
|
||||
countries.map(({ name, id }) => ({ label: name, value: id }))
|
||||
);
|
||||
|
||||
const editDetailsForm = computed(() =>
|
||||
Object.keys(FORM_CONFIG).map(key => ({
|
||||
key,
|
||||
@@ -202,14 +204,9 @@ const getMessageType = key => {
|
||||
: 'info';
|
||||
};
|
||||
|
||||
const handleCountryChange = country => {
|
||||
state.additionalAttributes.countryCode = country?.id || '';
|
||||
state.additionalAttributes.country = country?.name || '';
|
||||
emit('update', state);
|
||||
};
|
||||
|
||||
const handleCompanyChange = company => {
|
||||
state.additionalAttributes.companyName = company?.name || '';
|
||||
const handleCountrySelection = value => {
|
||||
const selectedCountry = countries.find(option => option.id === value);
|
||||
state.additionalAttributes.country = selectedCountry?.name || '';
|
||||
emit('update', state);
|
||||
};
|
||||
|
||||
@@ -239,49 +236,41 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 w-full">
|
||||
<div class="flex flex-col items-start gap-5 w-full">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.TITLE') }}
|
||||
</span>
|
||||
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<template v-for="item in editDetailsForm" :key="item.key">
|
||||
<CountryDropdown
|
||||
<ComboBox
|
||||
v-if="item.key === 'COUNTRY'"
|
||||
v-model="state.additionalAttributes.countryCode"
|
||||
:options="countryOptions"
|
||||
:placeholder="item.placeholder"
|
||||
class="[&>div>button]:h-8"
|
||||
:class="{
|
||||
'[&>div>button]:h-8': !isDetailsView,
|
||||
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
|
||||
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
|
||||
!isDetailsView,
|
||||
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
|
||||
}"
|
||||
@change="handleCountryChange"
|
||||
@update:model-value="handleCountrySelection"
|
||||
/>
|
||||
<PhoneNumberInput
|
||||
v-else-if="item.key === 'PHONE_NUMBER'"
|
||||
v-model="getFormBinding(item.key).value"
|
||||
:placeholder="item.placeholder"
|
||||
:show-border="isDetailsView"
|
||||
:compact="!isDetailsView"
|
||||
/>
|
||||
<CompaniesDropdown
|
||||
v-else-if="item.key === 'COMPANY_NAME'"
|
||||
:placeholder="item.placeholder"
|
||||
:selected-company-name="state.additionalAttributes.companyName"
|
||||
:class="{
|
||||
'[&>div>button]:h-8': !isDetailsView,
|
||||
'[&_button]:!h-10 [&_button]:!rounded-[10px]': isDetailsView,
|
||||
}"
|
||||
@change="handleCompanyChange"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
v-model="getFormBinding(item.key).value"
|
||||
:placeholder="item.placeholder"
|
||||
:message-type="getMessageType(item.key)"
|
||||
:custom-input-class="`!pt-1 !pb-1 ${
|
||||
:custom-input-class="`h-8 !pt-1 !pb-1 ${
|
||||
!isDetailsView
|
||||
? '[&:not(.error,.focus)]:!outline-transparent h-8'
|
||||
: 'h-10 !rounded-[10px]'
|
||||
? '[&:not(.error,.focus)]:!outline-transparent'
|
||||
: ''
|
||||
}`"
|
||||
class="w-full"
|
||||
@input="
|
||||
@@ -296,19 +285,18 @@ defineExpose({
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-5">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<span class="py-1 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.SOCIAL_MEDIA.TITLE') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="item in socialProfilesForm"
|
||||
:key="item.key"
|
||||
class="flex items-center h-8 gap-2 px-2 bg-n-alpha-2 dark:bg-n-solid-2"
|
||||
class="flex items-center h-8 gap-2 px-2 rounded-lg"
|
||||
:class="{
|
||||
'rounded-[10px] outline-1 outline outline-n-weak -outline-offset-1 hover:outline-n-slate-6':
|
||||
isDetailsView,
|
||||
'rounded-lg': !isDetailsView,
|
||||
'bg-n-alpha-2 dark:bg-n-solid-2': isDetailsView,
|
||||
'bg-n-alpha-2 dark:bg-n-solid-3': !isDetailsView,
|
||||
}"
|
||||
>
|
||||
<Icon
|
||||
|
||||
+1
-6
@@ -40,12 +40,7 @@ defineExpose({ dialogRef, contactsFormRef, onSuccess });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
width="3xl"
|
||||
overflow-y-auto
|
||||
@confirm="handleDialogConfirm"
|
||||
>
|
||||
<Dialog ref="dialogRef" width="3xl" @confirm="handleDialogConfirm">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
is-new-contact
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import ContactSortMenu from './components/ContactSortMenu.vue';
|
||||
import ContactMoreActions from './components/ContactMoreActions.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import DisplayPropertiesMenu from './components/DisplayPropertiesMenu.vue';
|
||||
|
||||
defineProps({
|
||||
showSearch: { type: Boolean, default: true },
|
||||
@@ -32,25 +29,17 @@ const emit = defineEmits([
|
||||
'createSegment',
|
||||
'deleteSegment',
|
||||
]);
|
||||
|
||||
const [showDisplayProperties, toggleDisplayProperties] = useToggle(false);
|
||||
|
||||
const closeDisplayProperties = () => {
|
||||
if (showDisplayProperties.value) {
|
||||
toggleDisplayProperties(false);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-10">
|
||||
<div
|
||||
class="flex items-start sm:items-center justify-between w-full py-4 px-6 gap-2 mx-auto after:absolute after:inset-x-0 after:-bottom-4 after:bg-gradient-to-b after:from-n-surface-1 after:from-10% after:dark:from-0% after:to-transparent after:h-4 after:pointer-events-none"
|
||||
class="flex items-start sm:items-center justify-between w-full py-6 px-6 gap-2 mx-auto max-w-[60rem]"
|
||||
>
|
||||
<span class="text-heading-1 truncate text-n-slate-12">
|
||||
<span class="text-xl font-medium truncate text-n-slate-12">
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-3">
|
||||
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4">
|
||||
<div v-if="showSearch" class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
:model-value="searchValue"
|
||||
@@ -70,23 +59,7 @@ const closeDisplayProperties = () => {
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div class="flex items-center flex-shrink-0 gap-2">
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<div v-on-click-outside="closeDisplayProperties" class="relative">
|
||||
<Button
|
||||
icon="i-lucide-settings-2"
|
||||
color="slate"
|
||||
:label="$t('CONTACTS_LAYOUT.HEADER.DISPLAY_PROPERTIES')"
|
||||
size="sm"
|
||||
:variant="!showDisplayProperties ? 'ghost' : 'faded'"
|
||||
@click="toggleDisplayProperties()"
|
||||
/>
|
||||
<DisplayPropertiesMenu
|
||||
v-if="showDisplayProperties"
|
||||
class="absolute top-full mt-1 max-md:ltr:left-0 max-md:rtl:right-0 md:ltr:right-0 md:rtl:left-0"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong mx-1" />
|
||||
<div class="flex items-center flex-shrink-0 gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="!isLabelView && !isActiveView" class="relative">
|
||||
<Button
|
||||
@@ -139,9 +112,7 @@ const closeDisplayProperties = () => {
|
||||
@export="emit('export')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="w-px h-3 rounded-lg bg-n-strong ltr:ml-1 rtl:mr-2 ltr:mr-1 rtl:ml-2"
|
||||
/>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
|
||||
+1
-1
@@ -294,7 +294,7 @@ defineExpose({
|
||||
>
|
||||
<template #filter>
|
||||
<div
|
||||
class="absolute z-20 mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
class="absolute mt-1 ltr:-right-52 rtl:-left-52 sm:ltr:right-0 sm:rtl:left-0 top-full"
|
||||
>
|
||||
<ContactsFilter
|
||||
v-if="showFiltersModal"
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ const handleOrderChange = value => {
|
||||
<div
|
||||
v-if="isMenuOpen"
|
||||
v-on-clickaway="() => (isMenuOpen = false)"
|
||||
class="absolute z-20 top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
class="absolute top-full mt-1 ltr:-right-32 rtl:-left-32 sm:ltr:right-0 sm:rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ const activeFilterQueryData = computed(() => {
|
||||
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
|
||||
"
|
||||
:show-clear-button="!hasActiveSegments"
|
||||
class="max-w-[105rem] px-6 relative z-20"
|
||||
class="max-w-[60rem] px-6"
|
||||
@open-filter="emit('openFilter')"
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
|
||||
-162
@@ -1,162 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const contactAttributes = useMapGetter('attributes/getContactAttributes');
|
||||
|
||||
const MAX_SELECTED = 3;
|
||||
|
||||
const selectedAttributeKeys = computed({
|
||||
get: () => uiSettings.value?.contact_list_display_properties || [],
|
||||
set: value => {
|
||||
updateUISettings({
|
||||
contact_list_display_properties: value,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const selectedAttributes = computed(() => {
|
||||
return (
|
||||
contactAttributes.value?.filter(attr =>
|
||||
selectedAttributeKeys.value.includes(attr.attributeKey)
|
||||
) || []
|
||||
);
|
||||
});
|
||||
|
||||
const otherAttributes = computed(() => {
|
||||
return (
|
||||
contactAttributes.value?.filter(
|
||||
attr => !selectedAttributeKeys.value.includes(attr.attributeKey)
|
||||
) || []
|
||||
);
|
||||
});
|
||||
|
||||
const canSelectMore = computed(() => {
|
||||
return selectedAttributeKeys.value.length < MAX_SELECTED;
|
||||
});
|
||||
|
||||
const toggleAttribute = attributeKey => {
|
||||
const currentKeys = [...selectedAttributeKeys.value];
|
||||
const index = currentKeys.indexOf(attributeKey);
|
||||
|
||||
if (index > -1) {
|
||||
currentKeys.splice(index, 1);
|
||||
} else if (currentKeys.length < MAX_SELECTED) {
|
||||
currentKeys.push(attributeKey);
|
||||
}
|
||||
|
||||
selectedAttributeKeys.value = currentKeys;
|
||||
};
|
||||
|
||||
const removeAttribute = attributeKey => {
|
||||
const currentKeys = [...selectedAttributeKeys.value];
|
||||
const index = currentKeys.indexOf(attributeKey);
|
||||
if (index > -1) {
|
||||
currentKeys.splice(index, 1);
|
||||
selectedAttributeKeys.value = currentKeys;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2 px-3 pt-3 pb-4 bg-n-alpha-3 rounded-xl outline outline-1 outline-n-weak -outline-offset-1 w-80 z-20 backdrop-blur-md"
|
||||
>
|
||||
<div class="flex items-start gap-1 flex-col">
|
||||
<h3 class="text-heading-3 text-n-slate-12 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-body-main text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedAttributes.length > 0" class="flex flex-col gap-2">
|
||||
<span class="pt-2 text-label-small text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.SELECTED_ATTRIBUTES') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="attribute in selectedAttributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg bg-n-button-color shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8"
|
||||
>
|
||||
<div class="size-5 flex items-center justify-center">
|
||||
<Checkbox
|
||||
model-value
|
||||
@change="removeAttribute(attribute.attributeKey)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
<Button
|
||||
icon="i-lucide-x"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
class="!size-4 !rounded-md"
|
||||
@click="removeAttribute(attribute.attributeKey)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="otherAttributes.length > 0" class="flex flex-col gap-2">
|
||||
<span class="pt-2 text-label-small text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.OTHER_ATTRIBUTES') }}
|
||||
</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="attribute in otherAttributes"
|
||||
:key="attribute.attributeKey"
|
||||
class="inline-flex items-center gap-1 ltr:pl-1 rtl:pr-1 ltr:pr-2 rtl:pl-2 rounded-lg shadow-[0_1px_2px_0_rgba(27,28,29,0.04)] outline outline-1 -outline-offset-1 outline-n-container h-8 bg-n-button-color"
|
||||
:class="[
|
||||
canSelectMore ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed',
|
||||
]"
|
||||
@click="canSelectMore && toggleAttribute(attribute.attributeKey)"
|
||||
>
|
||||
<div class="size-5 flex items-center justify-center">
|
||||
<Checkbox
|
||||
:model-value="false"
|
||||
:disabled="!canSelectMore"
|
||||
@change="toggleAttribute(attribute.attributeKey)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-body-main text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="attribute.attributeDescription"
|
||||
v-tooltip.top="{
|
||||
content: attribute.attributeDescription,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
icon="i-lucide-info"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
:disabled="!canSelectMore"
|
||||
class="!size-4 !rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contactAttributes.length === 0"
|
||||
class="flex flex-col items-center justify-center gap-3 py-8"
|
||||
>
|
||||
<p class="text-body-main text-center text-n-slate-11 m-0">
|
||||
{{ t('CONTACTS_LAYOUT.DISPLAY_PROPERTIES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -65,7 +65,7 @@ const openFilter = () => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-surface"
|
||||
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<ContactListHeaderWrapper
|
||||
@@ -86,7 +86,7 @@ const openFilter = () => {
|
||||
@clear-filters="emit('clearFilters')"
|
||||
/>
|
||||
<main class="flex-1 overflow-y-auto">
|
||||
<div class="w-full mx-auto">
|
||||
<div class="w-full mx-auto max-w-[60rem]">
|
||||
<ContactsActiveFiltersPreview
|
||||
v-if="showActiveFiltersPreview"
|
||||
:active-segment="activeSegment"
|
||||
@@ -96,7 +96,7 @@ const openFilter = () => {
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0">
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-0 px-4 pb-4">
|
||||
<PaginationFooter
|
||||
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
|
||||
:current-page="currentPage"
|
||||
|
||||
+3
-8
@@ -9,7 +9,6 @@ import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttrib
|
||||
import CheckboxAttribute from 'dashboard/components-next/CustomAttributes/CheckboxAttribute.vue';
|
||||
import DateAttribute from 'dashboard/components-next/CustomAttributes/DateAttribute.vue';
|
||||
import OtherAttribute from 'dashboard/components-next/CustomAttributes/OtherAttribute.vue';
|
||||
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attribute: {
|
||||
@@ -76,15 +75,11 @@ const CurrentAttributeComponent = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[auto,auto] group/attribute items-center w-full gap-4"
|
||||
class="grid grid-cols-[140px,1fr] group/attribute items-center w-full gap-2"
|
||||
:class="isEditingView ? 'min-h-10' : 'min-h-11'"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<HelperTextPopup
|
||||
v-if="attribute.attributeDescription"
|
||||
:message="attribute.attributeDescription"
|
||||
/>
|
||||
<span class="text-body-main truncate text-n-slate-12">
|
||||
<div class="flex items-center justify-between truncate">
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ attribute.attributeDisplayName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-4 px-5 pt-4 pb-6">
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
@@ -117,16 +117,16 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-2">
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
<span class="text-xs font-420 text-n-slate-10">{{
|
||||
<span class="text-sm font-medium text-n-slate-10">{{
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}</span>
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
@@ -135,7 +135,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pr-10 rtl:pl-2 mb-0 reset-base outline-n-weak outline-1 outline hover:outline-n-slate-6 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12 outline-offset-0"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
+11
-8
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
@@ -14,6 +13,11 @@ const route = useRoute();
|
||||
const conversations = useMapGetter(
|
||||
'contactConversations/getAllConversationsByContactId'
|
||||
);
|
||||
const contactsById = useMapGetter('contacts/getContactById');
|
||||
const stateInbox = useMapGetter('inboxes/getInboxById');
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const accountLabelsValue = computed(() => accountLabels.value);
|
||||
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.isFetching);
|
||||
@@ -32,17 +36,16 @@ const contactConversations = computed(() =>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="contactConversations.length > 0"
|
||||
class="px-6 pt-4 pb-6 divide-y divide-n-weak"
|
||||
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
:key="conversation.id"
|
||||
:chat="useSnakeCase(conversation, { deep: true })"
|
||||
enable-context-menu
|
||||
compact
|
||||
show-assignee
|
||||
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
|
||||
:enable-selection="false"
|
||||
:conversation="conversation"
|
||||
:contact="contactsById(conversation.meta.sender.id)"
|
||||
:state-inbox="stateInbox(conversation.inboxId)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
|
||||
+11
-10
@@ -103,12 +103,12 @@ const onMergeContacts = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 px-5 pb-6 pt-4">
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
<h4 class="text-base font-medium text-n-slate-12">
|
||||
<div class="flex flex-col gap-8 px-6 py-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-base text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
||||
</h4>
|
||||
<p class="text-sm font-420 text-n-slate-11">
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -126,6 +126,13 @@ const onMergeContacts = async () => {
|
||||
@search="onContactSearch"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
@@ -133,12 +140,6 @@ const onMergeContacts = async () => {
|
||||
:disabled="isMergingContact"
|
||||
@click="onMergeContacts"
|
||||
/>
|
||||
<Button
|
||||
slate
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full"
|
||||
@click="resetState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,12 +55,12 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 pt-4 pb-6">
|
||||
<div class="flex flex-col gap-6 py-6">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
focus-on-mount
|
||||
class="[&>div]:px-4 [&>div]:py-4 px-5"
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -87,7 +87,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="mx-5 py-4"
|
||||
class="mx-6 py-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
|
||||
+22
-20
@@ -48,26 +48,10 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1.5 border-b border-n-strong group/note">
|
||||
<div class="flex items-center justify-between w-full gap-1.5 min-w-0 h-9">
|
||||
<div class="min-w-0 truncate text-n-slate-11 text-xs font-440">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
ghost
|
||||
ruby
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
class="opacity-0 group-hover/note:opacity-100"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 border-b border-n-strong group/note">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Avatar
|
||||
v-tooltip.left="{
|
||||
content: writtenBy,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="note?.user?.name || 'Bot'"
|
||||
:src="
|
||||
note?.user?.name
|
||||
@@ -77,12 +61,30 @@ onMounted(() => {
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
<div class="min-w-0 truncate">
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
|
||||
<span class="font-medium text-n-slate-12">
|
||||
{{ dynamicTime(note.createdAt) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-if="allowDelete"
|
||||
variant="faded"
|
||||
color="ruby"
|
||||
size="xs"
|
||||
icon="i-lucide-trash"
|
||||
class="opacity-0 group-hover/note:opacity-100"
|
||||
@click="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
ref="noteContentRef"
|
||||
v-dompurify-html="formatMessage(note.content || '')"
|
||||
class="mb-0 text-body-main prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
|
||||
:class="{
|
||||
'line-clamp-4': collapsible && !isExpanded && needsCollapse,
|
||||
}"
|
||||
|
||||
@@ -11,7 +11,6 @@ import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/Cont
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -25,8 +24,6 @@ const emit = defineEmits(['goToContactsList']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const wrapperRef = ref(null);
|
||||
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
|
||||
const avatarFile = ref(null);
|
||||
@@ -124,17 +121,17 @@ const handleAvatarDelete = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-6 pb-6 w-full">
|
||||
<div ref="wrapperRef" class="flex flex-col items-start gap-3 w-full">
|
||||
<div class="flex flex-col items-start gap-8 pb-6">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<Avatar
|
||||
:src="avatarSrc || ''"
|
||||
:name="selectedContact?.name || ''"
|
||||
:size="64"
|
||||
:size="72"
|
||||
allow-upload
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ selectedContact?.name }}
|
||||
</h3>
|
||||
@@ -143,44 +140,27 @@ const handleAvatarDelete = async () => {
|
||||
v-if="selectedContact?.identifier"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
||||
>
|
||||
<Icon
|
||||
icon="i-ph-user-gear"
|
||||
class="text-n-slate-10 size-4 flex-shrink-0"
|
||||
/>
|
||||
<span class="i-ph-user-gear text-n-slate-10 size-4" />
|
||||
{{ selectedContact?.identifier }}
|
||||
</span>
|
||||
<span class="inline-flex items-start gap-1 text-sm text-n-slate-11">
|
||||
<Icon
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span
|
||||
v-if="selectedContact?.identifier"
|
||||
icon="i-ph-activity"
|
||||
class="text-n-slate-10 size-4 flex-shrink-0 mt-0.5"
|
||||
class="i-ph-activity text-n-slate-10 size-4"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:inline-flex items-start sm:items-center gap-1 sm:gap-3"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
$t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt })
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-xl bg-n-weak hidden sm:block" />
|
||||
<span>
|
||||
{{
|
||||
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||
date: lastActivityAt,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
||||
•
|
||||
{{
|
||||
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||
date: lastActivityAt,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ContactLabels
|
||||
:wrapper-ref="wrapperRef"
|
||||
:contact-id="selectedContact?.id"
|
||||
/>
|
||||
<ContactLabels :contact-id="selectedContact?.id" />
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-6 w-full">
|
||||
<div class="flex flex-col items-start gap-6">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@@ -195,23 +175,21 @@ const handleAvatarDelete = async () => {
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']" class="w-full">
|
||||
<div class="w-full h-px bg-n-slate-3 mt-1 mb-7" />
|
||||
<Policy :permissions="['administrator']">
|
||||
<div
|
||||
class="flex items-end justify-between w-full gap-4 outline outline-1 outline-n-weak rounded-2xl p-4"
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm font-420 text-n-slate-11">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
ruby
|
||||
class="flex-shrink-0"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
ExceptionWithMessage,
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
@@ -30,10 +28,7 @@ const route = useRoute();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
const selectedContactId = ref(null);
|
||||
const composeConversationRef = ref(null);
|
||||
const confirmDeleteContactDialogRef = ref(null);
|
||||
const contactToDelete = ref(null);
|
||||
const hoveredAvatarId = ref(null);
|
||||
|
||||
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||
|
||||
@@ -71,42 +66,27 @@ const onClickViewDetails = async id => {
|
||||
await router.push({ name, params, query: route.query });
|
||||
};
|
||||
|
||||
const toggleExpanded = async id => {
|
||||
const isExpanding = expandedCardId.value !== id;
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
|
||||
// Fetch contactable inboxes when expanding a card
|
||||
if (isExpanding) {
|
||||
try {
|
||||
await store.dispatch('contacts/fetchContactableInbox', id);
|
||||
} catch (error) {
|
||||
// error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
|
||||
const shouldShowSelection = id => {
|
||||
return hoveredAvatarId.value === id || isSelected(id);
|
||||
};
|
||||
|
||||
const handleSelect = (id, value) => {
|
||||
emit('toggleContact', { id, value });
|
||||
};
|
||||
|
||||
const handleSendMessage = id => {
|
||||
selectedContactId.value = String(id);
|
||||
composeConversationRef.value?.toggle();
|
||||
};
|
||||
|
||||
const handleDeleteContact = id => {
|
||||
const contact = props.contacts.find(c => c.id === id);
|
||||
if (contact) {
|
||||
contactToDelete.value = { id: contact.id, name: contact.name };
|
||||
confirmDeleteContactDialogRef.value?.dialogRef.open();
|
||||
}
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col divide-y divide-n-slate-3">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||
<ContactsCard
|
||||
:id="contact.id"
|
||||
@@ -115,37 +95,17 @@ const handleDeleteContact = id => {
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:custom-attributes="contact.customAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
:selectable="shouldShowSelection(contact.id)"
|
||||
:is-selected="isSelected(contact.id)"
|
||||
:labels="contact.labels || []"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
@select="value => handleSelect(contact.id, value)"
|
||||
@send-message="handleSendMessage"
|
||||
@delete-contact="handleDeleteContact"
|
||||
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||
/>
|
||||
</div>
|
||||
<ComposeConversation
|
||||
ref="composeConversationRef"
|
||||
:contact-id="selectedContactId"
|
||||
is-modal
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="hidden"
|
||||
aria-hidden="true"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="contactToDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import { useCallsStore } from 'dashboard/stores/calls';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
@@ -68,17 +67,6 @@ const startCall = async inboxId => {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
const { call_sid: callSid, conversation_id: conversationId } = response;
|
||||
|
||||
// Add call to store immediately so widget shows
|
||||
const callsStore = useCallsStore();
|
||||
callsStore.addCall({
|
||||
callSid,
|
||||
conversationId,
|
||||
inboxId,
|
||||
callDirection: 'outbound',
|
||||
});
|
||||
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(response?.conversation_id);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,23 +0,0 @@
|
||||
<script setup>
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
|
||||
defineProps({
|
||||
contactName: { type: String, required: true },
|
||||
timestamp: { type: Number, required: true },
|
||||
createdAt: { type: Number, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto] items-center gap-2 h-5">
|
||||
<h4 class="text-sm my-0 capitalize truncate text-n-slate-12 font-medium">
|
||||
{{ contactName }}
|
||||
</h4>
|
||||
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="timestamp"
|
||||
:created-at-timestamp="createdAt"
|
||||
class="font-440 !text-xs shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+64
-160
@@ -1,191 +1,95 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
inject,
|
||||
nextTick,
|
||||
useSlots,
|
||||
watch,
|
||||
useAttrs,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const WIDTH_CONFIG = Object.freeze({
|
||||
DEFAULT_WIDTH: 80,
|
||||
CHAR_WIDTH: {
|
||||
SHORT: 8, // For labels <= 5 chars
|
||||
LONG: 6, // For labels > 5 chars
|
||||
},
|
||||
BASE_WIDTH: 12, // dot + gap
|
||||
THRESHOLD: 5, // character length threshold
|
||||
});
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
const containerRef = ref(null);
|
||||
const maxLabels = ref(1);
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
const labelSet = new Set(props.conversationLabels);
|
||||
return props.accountLabels?.filter(({ title }) => labelSet.has(title));
|
||||
});
|
||||
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
const calculateLabelWidth = ({ title = '' }) => {
|
||||
const charWidth =
|
||||
title.length > WIDTH_CONFIG.THRESHOLD
|
||||
? WIDTH_CONFIG.CHAR_WIDTH.LONG
|
||||
: WIDTH_CONFIG.CHAR_WIDTH.SHORT;
|
||||
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
return title.length * charWidth + WIDTH_CONFIG.BASE_WIDTH;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
const getAverageWidth = labels => {
|
||||
if (!labels.length) return WIDTH_CONFIG.DEFAULT_WIDTH;
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
const totalWidth = labels.reduce(
|
||||
(sum, label) => sum + calculateLabelWidth(label),
|
||||
0
|
||||
);
|
||||
|
||||
// Recalculate when parent triggers (e.g., tab changes in ChatList)
|
||||
// Needed because inbox conversation view shows metadata inside CardLabels, affecting available width
|
||||
const recalculateKey = inject('recalculateLabelsKey', null);
|
||||
return totalWidth / labels.length;
|
||||
};
|
||||
|
||||
if (recalculateKey) {
|
||||
watch(recalculateKey, () => nextTick(throttledCalculate));
|
||||
}
|
||||
const visibleLabels = computed(() =>
|
||||
activeLabels.value?.slice(0, maxLabels.value)
|
||||
);
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
const updateVisibleLabels = () => {
|
||||
if (!containerRef.value) return;
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
const containerWidth = containerRef.value.offsetWidth;
|
||||
const avgWidth = getAverageWidth(activeLabels.value);
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
maxLabels.value = Math.max(1, Math.floor(containerWidth / avgWidth));
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
ref="containerRef"
|
||||
v-resize="updateVisibleLabels"
|
||||
class="flex items-center gap-2.5 w-full min-w-0 h-6 overflow-hidden"
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 1000, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
<template v-for="(label, index) in visibleLabels" :key="label.id">
|
||||
<div
|
||||
class="flex items-center gap-1.5 min-w-0"
|
||||
:class="[
|
||||
index !== visibleLabels.length - 1
|
||||
? 'flex-shrink-0 text-ellipsis'
|
||||
: 'flex-shrink',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:style="{ backgroundColor: label.color }"
|
||||
class="size-1.5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
class="text-sm text-n-slate-10 whitespace-nowrap"
|
||||
:class="{ truncate: index === visibleLabels.length - 1 }"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-end w-full gap-2 pb-1">
|
||||
<p class="w-full mb-0 text-sm leading-7 text-n-slate-12 line-clamp-2">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
<div class="flex items-center flex-shrink-0 gap-2 pb-2">
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardLabels from 'dashboard/components-next/Conversation/ConversationCard/CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/ConversationCard/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const slaCardLabelRef = ref(null);
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const lastNonActivityMessageContent = computed(() => {
|
||||
const { lastNonActivityMessage = {}, customAttributes = {} } =
|
||||
props.conversation;
|
||||
const { email: { subject } = {} } = customAttributes;
|
||||
return getPlainText(
|
||||
subject || lastNonActivityMessage?.content || t('CHAT_LIST.NO_CONTENT')
|
||||
);
|
||||
});
|
||||
|
||||
const assignee = computed(() => {
|
||||
const { meta: { assignee: agent = {} } = {} } = props.conversation;
|
||||
return {
|
||||
name: agent.name ?? agent.availableName,
|
||||
thumbnail: agent.thumbnail,
|
||||
status: agent.availabilityStatus,
|
||||
};
|
||||
});
|
||||
|
||||
const unreadMessagesCount = computed(() => {
|
||||
const { unreadCount } = props.conversation;
|
||||
return unreadCount;
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
return (
|
||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
||||
);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<div class="flex items-center justify-between w-full gap-2 py-1 h-7">
|
||||
<p class="mb-0 text-sm leading-7 text-n-slate-12 line-clamp-1">
|
||||
{{ lastNonActivityMessageContent }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="unreadMessagesCount > 0"
|
||||
class="inline-flex items-center justify-center flex-shrink-0 rounded-full size-5 bg-n-brand"
|
||||
>
|
||||
<span class="text-xs font-semibold text-white">
|
||||
{{ unreadMessagesCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid items-center gap-2.5 h-7"
|
||||
:class="
|
||||
hasSlaThreshold
|
||||
? 'grid-cols-[auto_auto_1fr_20px]'
|
||||
: 'grid-cols-[1fr_20px]'
|
||||
"
|
||||
>
|
||||
<SLACardLabel
|
||||
v-show="hasSlaThreshold"
|
||||
ref="slaCardLabelRef"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<div v-if="hasSlaThreshold" class="w-px h-3 bg-n-slate-4" />
|
||||
<div class="overflow-hidden">
|
||||
<CardLabels
|
||||
:conversation-labels="conversation.labels"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="20"
|
||||
:status="assignee.status"
|
||||
rounded-full
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
inbox: { type: Object, required: true },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inline: { type: Boolean, default: false },
|
||||
isLabelsEmpty: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const showSection = computed(() => {
|
||||
if (props.inline) {
|
||||
return (
|
||||
hasSlaPolicyId.value ||
|
||||
props.chat.priority ||
|
||||
(props.showAssignee && props.assignee.name)
|
||||
);
|
||||
}
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority ||
|
||||
props.chat.status ||
|
||||
hasSlaPolicyId.value
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Full meta section (when not inline) -->
|
||||
<div
|
||||
v-if="showSection && !inline"
|
||||
class="flex items-center justify-between min-w-0 gap-8 h-6 mb-1"
|
||||
>
|
||||
<div class="flex-1 min-w-0 flex items-center gap-0.5">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<CardPriorityIcon
|
||||
v-if="chat.priority"
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon v-if="chat.status" :status="chat.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inline meta section (compact mode for cases in inbox and label filter view) -->
|
||||
<div
|
||||
v-else-if="showSection && inline"
|
||||
data-before-slot
|
||||
class="flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<CardPriorityIcon
|
||||
v-if="chat.priority"
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0"
|
||||
/>
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="assignee.name"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<CardStatusIcon v-if="chat.status" :status="chat.status" />
|
||||
</div>
|
||||
<div v-if="hasSlaPolicyId" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
|
||||
<SLACardLabel v-if="hasSlaPolicyId" ref="slaCardLabel" :chat="chat" />
|
||||
<div v-if="!isLabelsEmpty" class="rounded-lg h-2 w-px bg-n-strong mx-0.5" />
|
||||
</div>
|
||||
</template>
|
||||
+195
-30
@@ -1,42 +1,207 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_PRIORITY } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
priority: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_PRIORITY.URGENT]: 'i-woot-priority-urgent',
|
||||
[CONVERSATION_PRIORITY.HIGH]: 'i-woot-priority-high',
|
||||
[CONVERSATION_PRIORITY.MEDIUM]: 'i-woot-priority-medium',
|
||||
[CONVERSATION_PRIORITY.LOW]: 'i-woot-priority-low',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.priority && icons[props.priority]) {
|
||||
return icons[props.priority];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-priority-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-static-inline-styles -->
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: priority,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 text-n-slate-5"
|
||||
/>
|
||||
<div class="inline-flex items-center justify-center rounded-md">
|
||||
<!-- Low Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.LOW"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Medium Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.MEDIUM"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-slate-6"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- High Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.HIGH"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-amber-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<!-- Urgent Priority -->
|
||||
<svg
|
||||
v-if="priority === CONVERSATION_PRIORITY.URGENT"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_2030_12879"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="20"
|
||||
height="20"
|
||||
>
|
||||
<rect width="20" height="20" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_2030_12879)">
|
||||
<rect
|
||||
x="3.33301"
|
||||
y="10"
|
||||
width="3.33333"
|
||||
height="6.66667"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="8.33301"
|
||||
y="6.6665"
|
||||
width="3.33333"
|
||||
height="10"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
<rect
|
||||
x="13.333"
|
||||
y="3.3335"
|
||||
width="3.33333"
|
||||
height="13.3333"
|
||||
rx="1.66667"
|
||||
class="fill-n-ruby-9"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-42
@@ -1,42 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import ConversationCard from './ConversationCard.vue';
|
||||
|
||||
// Base conversation object
|
||||
const conversationWithoutMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phone_number: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
last_activity_at: 1712127410,
|
||||
created_at: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 38,
|
||||
messages: [
|
||||
{
|
||||
id: 3597,
|
||||
content: 'Sivin set the priority to low',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 38,
|
||||
messageType: 2,
|
||||
createdAt: 1730885168,
|
||||
updatedAt: '2024-11-06T09:26:08.565Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
contentType: 'text',
|
||||
contentAttributes: {},
|
||||
senderType: null,
|
||||
senderId: null,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent: 'Sivin set the priority to low',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assigneeId: 1,
|
||||
unreadCount: 0,
|
||||
lastActivityAt: 1730885168,
|
||||
contactInbox: {
|
||||
sourceId: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '21bd8638-a711-4080-b4ac-7fda1bc71837',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'Test email',
|
||||
},
|
||||
agentLastSeenAt: 0,
|
||||
assigneeLastSeenAt: 0,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [],
|
||||
status: 'open',
|
||||
createdAt: 1730836533,
|
||||
timestamp: 1730885168,
|
||||
firstReplyCreatedAt: 1730836533,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3591,
|
||||
content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 38,
|
||||
message_type: 1,
|
||||
created_at: 1730836533,
|
||||
updated_at: '2024-11-05T19:55:37.158Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/21bd8638-a711-4080-b4ac-7fda1bc71837/messages/3591@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: ['test@gmail.com'],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'Hello, I bought some paper but they did not come with the indices as we had assumed. Was there a change in the product line?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885168,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885168,
|
||||
priority: 'low',
|
||||
waitingSince: 0,
|
||||
slaPolicyId: null,
|
||||
slaEvents: [],
|
||||
};
|
||||
|
||||
const conversationWithMeta = {
|
||||
meta: {
|
||||
sender: {
|
||||
additionalAttributes: {},
|
||||
availabilityStatus: 'offline',
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
customAttributes: {
|
||||
linkContact: 'https://apple.com',
|
||||
listContact: 'Not spam',
|
||||
textContact: 'hey',
|
||||
checkboxContact: true,
|
||||
},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
},
|
||||
channel: 'Channel::Email',
|
||||
assignee: {
|
||||
id: 1,
|
||||
accountId: 2,
|
||||
availabilityStatus: 'online',
|
||||
autoOffline: false,
|
||||
confirmed: true,
|
||||
email: 'sivin@chatwoot.com',
|
||||
availableName: 'Sivin',
|
||||
name: 'Sivin',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
customRoleId: null,
|
||||
},
|
||||
hmacVerified: false,
|
||||
},
|
||||
id: 37,
|
||||
messages: [
|
||||
{
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
accountId: 2,
|
||||
inboxId: 7,
|
||||
conversationId: 37,
|
||||
messageType: 1,
|
||||
createdAt: 1730885428,
|
||||
updatedAt: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
sourceId:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
contentType: 'text',
|
||||
contentAttributes: {
|
||||
ccEmails: [],
|
||||
bccEmails: [],
|
||||
toEmails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
senderId: 1,
|
||||
externalSourceIds: {},
|
||||
additionalAttributes: {},
|
||||
processedMessageContent:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'candice@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
availableName: 'Sivin',
|
||||
avatarUrl: '',
|
||||
type: 'user',
|
||||
availabilityStatus: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
accountId: 2,
|
||||
uuid: '53df668d-329d-420e-8fe9-980cb0e4d63c',
|
||||
additionalAttributes: {
|
||||
mail_subject: 'we',
|
||||
},
|
||||
agentLastSeenAt: 1730885428,
|
||||
assigneeLastSeenAt: 1730885428,
|
||||
canReply: true,
|
||||
contactLastSeenAt: 0,
|
||||
customAttributes: {},
|
||||
inboxId: 7,
|
||||
labels: [
|
||||
'billing',
|
||||
'delivery',
|
||||
'lead',
|
||||
'premium-customer',
|
||||
'software',
|
||||
'ops-handover',
|
||||
],
|
||||
muted: false,
|
||||
snoozedUntil: null,
|
||||
status: 'open',
|
||||
createdAt: 1722487645,
|
||||
timestamp: 1730885428,
|
||||
firstReplyCreatedAt: 1722487645,
|
||||
unreadCount: 0,
|
||||
lastNonActivityMessage: {
|
||||
id: 3599,
|
||||
content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
account_id: 2,
|
||||
inbox_id: 7,
|
||||
conversation_id: 37,
|
||||
message_type: 1,
|
||||
created_at: 1730885428,
|
||||
updated_at: '2024-11-06T09:30:30.619Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id:
|
||||
'conversation/53df668d-329d-420e-8fe9-980cb0e4d63c/messages/3599@paperlayer.test',
|
||||
content_type: 'text',
|
||||
content_attributes: {
|
||||
cc_emails: [],
|
||||
bcc_emails: [],
|
||||
to_emails: [],
|
||||
},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content:
|
||||
'If you want to buy our premium supplies,we can offer you a 20% discount! They come with indices and lazer beams!',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 2,
|
||||
last_activity_at: 1730885428,
|
||||
contact_inbox: {
|
||||
source_id: 'willy@chatwoot.com',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Sivin',
|
||||
available_name: 'Sivin',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
lastActivityAt: 1730885428,
|
||||
priority: 'urgent',
|
||||
waitingSince: 1730885428,
|
||||
slaPolicyId: 3,
|
||||
appliedSla: {
|
||||
id: 4,
|
||||
sla_id: 3,
|
||||
sla_status: 'active_with_misses',
|
||||
created_at: 1712127410,
|
||||
updated_at: 1712127545,
|
||||
sla_description:
|
||||
'Premium Service Level Agreements (SLAs) are contracts that define clear expectations ',
|
||||
sla_name: 'Premium SLA',
|
||||
sla_first_response_time_threshold: 120,
|
||||
sla_next_response_time_threshold: 180,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 360,
|
||||
},
|
||||
slaEvents: [
|
||||
{
|
||||
id: 8,
|
||||
event_type: 'frt',
|
||||
meta: {},
|
||||
updated_at: 1712127545,
|
||||
created_at: 1712127545,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1712127790,
|
||||
created_at: 1712127790,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const contactForConversationWithoutMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'candice@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Candice Matherson',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=George',
|
||||
customAttributes: {},
|
||||
last_activity_at: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const contactForConversationWithMeta = computed(() => ({
|
||||
availabilityStatus: null,
|
||||
email: 'willy@chatwoot.com',
|
||||
id: 29,
|
||||
name: 'Willy Castelot',
|
||||
phoneNumber: '+918585858585',
|
||||
identifier: null,
|
||||
thumbnail: 'https://api.dicebear.com/9.x/dylan/svg?seed=Liam',
|
||||
customAttributes: {},
|
||||
lastActivityAt: 1712127410,
|
||||
createdAt: 1712127389,
|
||||
contactInboxes: [],
|
||||
}));
|
||||
|
||||
const webWidgetInbox = computed(() => ({
|
||||
phone_number: '+918585858585',
|
||||
channel_type: 'Channel::WebWidget',
|
||||
}));
|
||||
|
||||
const accountLabels = computed(() => [
|
||||
{
|
||||
id: 1,
|
||||
title: 'billing',
|
||||
description: 'Label is used for tagging billing related conversations',
|
||||
color: '#28AD21',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'delivery',
|
||||
description: null,
|
||||
color: '#A2FDD5',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'lead',
|
||||
description: null,
|
||||
color: '#F161C8',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'ops-handover',
|
||||
description: null,
|
||||
color: '#A53326',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'premium-customer',
|
||||
description: null,
|
||||
color: '#6FD4EF',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'software',
|
||||
description: null,
|
||||
color: '#8F6EF2',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ConversationCard"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Conversation without meta">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="conversationWithoutMeta"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
priority: 'medium',
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation without meta (Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithoutMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithoutMeta,
|
||||
unreadCount: 2,
|
||||
priority: 'high',
|
||||
}"
|
||||
:contact="contactForConversationWithoutMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
<Variant title="Conversation with meta (SLA, Labels, Unread count)">
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
:key="conversationWithMeta.id"
|
||||
:conversation="{
|
||||
...conversationWithMeta,
|
||||
unreadCount: 2,
|
||||
}"
|
||||
:contact="contactForConversationWithMeta"
|
||||
:state-inbox="webWidgetInbox"
|
||||
:account-labels="accountLabels"
|
||||
class="hover:bg-n-alpha-1"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+98
-208
@@ -1,243 +1,133 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
|
||||
import ConversationCardExpanded from './ConversationCardExpanded.vue';
|
||||
import ConversationCardCompact from './ConversationCardCompact.vue';
|
||||
import { getInboxIconByType } from 'dashboard/helper/inbox';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
|
||||
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import CardMessagePreview from './CardMessagePreview.vue';
|
||||
import CardMessagePreviewWithMeta from './CardMessagePreviewWithMeta.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
selected: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isExpandedLayout: { type: Boolean, default: false },
|
||||
isPreviousConversations: { type: Boolean, default: false },
|
||||
conversation: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
stateInbox: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
accountLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLargeScreen = breakpoints.greaterOrEqual('lg');
|
||||
const cardMessagePreviewWithMetaRef = ref(null);
|
||||
|
||||
// Show expanded only when: isExpandedLayout=true AND screen >= 1024px
|
||||
const showExpanded = computed(() => {
|
||||
return props.isExpandedLayout && isLargeScreen.value;
|
||||
});
|
||||
const currentContact = computed(() => props.contact);
|
||||
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? contactGetter.value(senderId.value) : {}
|
||||
const currentContactName = computed(() => currentContact.value?.name);
|
||||
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
|
||||
const currentContactStatus = computed(
|
||||
() => currentContact.value?.availabilityStatus
|
||||
);
|
||||
const hasActiveInbox = computed(() => activeInbox.value);
|
||||
|
||||
const isActiveChat = computed(() => currentChat.value.id === props.chat.id);
|
||||
const inbox = computed(() => {
|
||||
const inboxId = props.chat.inbox_id;
|
||||
return inboxId ? inboxGetter.value(inboxId) : {};
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => inboxesList.value.length > 1);
|
||||
const lastActivityAt = computed(() => {
|
||||
const timestamp = props.conversation?.timestamp;
|
||||
return timestamp ? shortTimestamp(dynamicTime(timestamp)) : '';
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
const showMessagePreviewWithoutMeta = computed(() => {
|
||||
const { labels = [] } = props.conversation;
|
||||
return (
|
||||
!cardMessagePreviewWithMetaRef.value?.hasSlaThreshold && labels.length === 0
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId: route.params.accountId,
|
||||
id: props.conversation.id,
|
||||
})
|
||||
);
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
window.chatwootConfig.hostURL + path,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
'noopener noreferrer nofollow'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Expanded: Only when isExpandedLayout=true AND screen >= 1024px -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@de-select-conversation="onSelectConversation"
|
||||
<div
|
||||
role="button"
|
||||
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Compact: All other cases (mobile OR isExpandedLayout=false) -->
|
||||
<ConversationCardCompact
|
||||
v-else
|
||||
:chat="chat"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="selected"
|
||||
:is-active-chat="isActiveChat"
|
||||
:compact="compact"
|
||||
:show-assignee="showAssignee"
|
||||
:show-inbox-name="showInboxName"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
:enable-selection="enableSelection"
|
||||
:is-inbox-view="hasActiveInbox && !isPreviousConversations"
|
||||
@select-conversation="onSelectConversation"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="chat.unread_count > 0"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
<Avatar
|
||||
:name="currentContactName"
|
||||
:src="currentContactThumbnail"
|
||||
:size="24"
|
||||
:status="currentContactStatus"
|
||||
rounded-full
|
||||
/>
|
||||
</ContextMenu>
|
||||
<div class="flex flex-col w-full gap-1 min-w-0">
|
||||
<div class="flex items-center justify-between h-6 gap-2">
|
||||
<h4 class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ currentContactName }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<CardPriorityIcon :priority="conversation.priority || null" />
|
||||
<div
|
||||
v-tooltip.left="inboxName"
|
||||
class="flex items-center justify-center flex-shrink-0 rounded-full bg-n-alpha-2 size-5"
|
||||
>
|
||||
<Icon
|
||||
:icon="inboxIcon"
|
||||
class="flex-shrink-0 text-n-slate-11 size-3"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10">
|
||||
{{ lastActivityAt }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardMessagePreview
|
||||
v-show="showMessagePreviewWithoutMeta"
|
||||
:conversation="conversation"
|
||||
/>
|
||||
<CardMessagePreviewWithMeta
|
||||
v-show="!showMessagePreviewWithoutMeta"
|
||||
ref="cardMessagePreviewWithMetaRef"
|
||||
:conversation="conversation"
|
||||
:account-labels="accountLabels"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardMetaSection from './CardMetaSection.vue';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardHeader from './CardHeader.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabels.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation', 'click', 'contextmenu']);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
const showExpandedPreview = computed(
|
||||
() => props.compact && !showLabelsSection.value
|
||||
);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat?.unread_count);
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
emit('selectConversation', checked);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full cursor-pointer group transition-all duration-150"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'px-0 py-3': compact,
|
||||
'px-2 pt-2.5 pb-3 hover:bg-n-alpha-1 rounded-lg': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div class="min-w-0 w-full">
|
||||
<CardMetaSection
|
||||
v-if="!isInboxView"
|
||||
:chat="chat"
|
||||
:inbox="inbox"
|
||||
:show-inbox-name="showInboxName"
|
||||
:show-assignee="showAssignee"
|
||||
:assignee="assignee"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="grid gap-2.5 items-start"
|
||||
:class="{
|
||||
'mt-0.5 grid-cols-1': compact,
|
||||
'grid-cols-[auto,1fr]': !compact,
|
||||
}"
|
||||
>
|
||||
<CardAvatar
|
||||
v-if="!compact"
|
||||
:contact="currentContact"
|
||||
:selected="selected"
|
||||
:enable-selection="enableSelection"
|
||||
:hide-thumbnail="hideThumbnail"
|
||||
class="mt-0.5"
|
||||
@select-conversation="onSelectConversation"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex flex-col gap-1.5">
|
||||
<div class="min-w-0 flex flex-col gap-px">
|
||||
<CardHeader
|
||||
v-if="!compact"
|
||||
:contact-name="currentContact.name"
|
||||
:timestamp="chat.timestamp"
|
||||
:created-at="chat.created_at"
|
||||
/>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="showExpandedPreview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardLabels
|
||||
v-if="showLabelsSection || isInboxView"
|
||||
:labels="chat.labels"
|
||||
>
|
||||
<template #before>
|
||||
<CardMetaSection
|
||||
v-if="isInboxView"
|
||||
:chat="chat"
|
||||
:inbox="inbox"
|
||||
:show-assignee="showAssignee"
|
||||
:assignee="assignee"
|
||||
:is-labels-empty="chat.labels.length === 0"
|
||||
inline
|
||||
/>
|
||||
<SLACardLabel
|
||||
v-else-if="hasSlaPolicyId"
|
||||
ref="slaCardLabel"
|
||||
data-before-slot
|
||||
:chat="chat"
|
||||
/>
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-182
@@ -1,182 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabels.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative cursor-pointer group transition-all duration-150 grid gap-4 items-center px-2 h-12"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3': isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3': selected,
|
||||
'hover:bg-n-alpha-1 rounded-lg': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-label-small text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user