Compare commits

..
Author SHA1 Message Date
Vishnu NarayananandGitHub 8b759496f2 Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-29 17:27:16 +05:30
Vishnu NarayananandGitHub 98fa5f60e9 Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-24 16:15:00 +05:30
Vishnu NarayananandGitHub 7a8829e79e Merge branch 'develop' into fix/missing-postmessage-validation 2026-04-23 18:22:15 +05:30
Shivam MishraandGitHub bf397750f3 Merge branch 'develop' into fix/missing-postmessage-validation 2026-01-21 13:42:18 +05:30
Vinay KeerthiandGitHub f253ea0abc Merge branch 'develop' into fix/missing-postmessage-validation 2026-01-15 11:38:30 +05:30
Vinay Keerthi 3aea425abc fix: handle same-origin deployments in postMessage validation
Fall back to window.location.origin when baseUrl is empty or relative,
supporting same-origin deployments while maintaining origin validation.
2026-01-12 16:34:13 +05:30
Vinay Keerthi eee307b36b fix: add postMessage origin validation in widget SDK
Adds origin validation for postMessage communication in the widget SDK:
- Extract and validate origin from configured baseUrl
- Use specific target origin instead of wildcard when sending messages
- Validate sender origin when receiving messages

Fixes: CW-6301
2026-01-12 16:27:52 +05:30
314 changed files with 2983 additions and 11200 deletions
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env python3
"""Sync triage GitHub security advisories to Linear issues."""
from __future__ import annotations
import os
import sys
from typing import Any
import requests
GITHUB_API = "https://api.github.com"
LINEAR_API = "https://api.linear.app/graphql"
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
SEVERITY_COLOR = {
"critical": 15548997,
"high": 15105570,
"medium": 15844367,
"low": 3066993,
}
DEFAULT_COLOR = 9807270
def required_env(name: str) -> str:
value = os.environ.get(name)
if not value:
sys.exit(f"Missing required env var: {name}")
return value
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
headers = {
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {token}",
"X-GitHub-Api-Version": "2022-11-28",
}
advisories: list[dict[str, Any]] = []
while url:
r = requests.get(url, headers=headers, params=params, timeout=30)
r.raise_for_status()
advisories.extend(r.json())
next_link = r.links.get("next")
url = next_link["url"] if next_link else None
params = None
return advisories
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
r = requests.post(
LINEAR_API,
headers={"Authorization": api_key},
json={"query": query, "variables": variables},
timeout=30,
)
r.raise_for_status()
return r.json()
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
query = (
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
"{ nodes { id } } }"
)
resp = linear_call(query, {"q": ghsa_id}, api_key)
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
query = (
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
"{ success issue { identifier url } } }"
)
resp = linear_call(query, {"input": input_data}, api_key)
create = resp.get("data", {}).get("issueCreate") or {}
if not create.get("success"):
return None
return create.get("issue")
def reporter_login(advisory: dict[str, Any]) -> str:
for credit in advisory.get("credits") or []:
user = (credit or {}).get("user") or {}
if user.get("login"):
return user["login"]
return "unknown"
def cvss_score(advisory: dict[str, Any]) -> str:
score = (advisory.get("cvss") or {}).get("score")
return str(score) if score is not None else "n/a"
def build_description(adv: dict[str, Any]) -> str:
return (
f"**GHSA:** {adv['ghsa_id']}\n"
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
f"**Reporter:** {reporter_login(adv)}\n"
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
f"**Advisory:** {adv['html_url']}\n\n"
f"---\n\n"
f"{adv.get('description') or 'No description provided.'}"
)
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
severity = adv.get("severity") or "unknown"
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
payload = {
"username": "GHSA Sync",
"embeds": [
{
"title": title,
"url": issue["url"],
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
"fields": [
{"name": "Linear", "value": issue["identifier"], "inline": True},
{
"name": "Severity",
"value": f"{severity} (CVSS {cvss_score(adv)})",
"inline": True,
},
{
"name": "Advisory",
"value": f"[GitHub]({adv['html_url']})",
"inline": True,
},
],
}
],
}
try:
requests.post(webhook_url, json=payload, timeout=10)
except requests.RequestException:
pass
def main() -> int:
repo = required_env("GITHUB_REPOSITORY")
gh_token = required_env("GHSA_READ_TOKEN")
linear_api_key = required_env("LINEAR_API_KEY")
team_id = required_env("LINEAR_TEAM_ID")
project_id = required_env("LINEAR_PROJECT_ID")
label_id = required_env("LINEAR_LABEL_ID")
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
advisories = fetch_triage_advisories(repo, gh_token)
print(f"Fetched {len(advisories)} triage advisories")
created = skipped = failed = 0
for adv in advisories:
ghsa_id = adv.get("ghsa_id")
if not ghsa_id:
failed += 1
continue
try:
if linear_issue_exists(ghsa_id, linear_api_key):
skipped += 1
continue
severity = adv.get("severity") or "unknown"
issue = linear_create_issue(
{
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
"description": build_description(adv),
"teamId": team_id,
"projectId": project_id,
"labelIds": [label_id],
"priority": SEVERITY_PRIORITY.get(severity, 3),
},
linear_api_key,
)
except requests.RequestException:
failed += 1
continue
if not issue:
failed += 1
continue
created += 1
if discord_webhook:
post_discord(adv, issue, discord_webhook)
print(f"Created {created}, skipped {skipped}, failed {failed}")
return 1 if failed > 0 else 0
if __name__ == "__main__":
sys.exit(main())
-3
View File
@@ -11,9 +11,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
deployment_check: deployment_check:
name: Check Deployment name: Check Deployment
-3
View File
@@ -8,9 +8,6 @@ on:
branches: branches:
- develop - develop
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
-29
View File
@@ -1,29 +0,0 @@
name: Sync GHSA advisories to Linear
on:
schedule:
- cron: '0 4 * * *' # daily at 09:30 IST
workflow_dispatch: {}
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: pip install requests==2.32.3
- name: Sync advisories
env:
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: python3 .github/scripts/ghsa_linear_sync.py
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
log_lines_check: log_lines_check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -14,9 +14,6 @@ on:
- cron: "0 0 * * *" - cron: "0 0 * * *"
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
nightly: nightly:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -3,10 +3,6 @@ name: Publish Codespace Base Image
on: on:
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
publish-code-space-image: publish-code-space-image:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-3
View File
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
@@ -18,9 +18,6 @@ on:
env: env:
DOCKER_REPO: chatwoot/chatwoot DOCKER_REPO: chatwoot/chatwoot
permissions:
contents: read
jobs: jobs:
build: build:
strategy: strategy:
-3
View File
@@ -10,9 +10,6 @@ concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }} group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true cancel-in-progress: true
permissions:
contents: read
jobs: jobs:
test: test:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
-3
View File
@@ -7,9 +7,6 @@ on:
- master - master
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
test-build: test-build:
strategy: strategy:
-1
View File
@@ -22,7 +22,6 @@ gem 'time_diff'
gem 'tzinfo-data' gem 'tzinfo-data'
gem 'valid_email2' gem 'valid_email2'
gem 'email-provider-info' gem 'email-provider-info'
gem 'gemoji'
# compress javascript config.assets.js_compressor # compress javascript config.assets.js_compressor
gem 'uglifier' gem 'uglifier'
##-- used for single column multiple binary flags in notification settings/feature flagging --## ##-- used for single column multiple binary flags in notification settings/feature flagging --##
+5 -7
View File
@@ -108,8 +108,8 @@ GEM
acts-as-taggable-on (12.0.0) acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1) activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0) zeitwerk (>= 2.4, < 3.0)
addressable (2.9.0) addressable (2.8.7)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1) administrate (0.20.1)
actionpack (>= 6.0, < 8.0) actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0) actionview (>= 6.0, < 8.0)
@@ -349,7 +349,6 @@ GEM
googleapis-common-protos-types (>= 1.3.1, < 2.a) googleapis-common-protos-types (>= 1.3.1, < 2.a)
googleauth (~> 1.0) googleauth (~> 1.0)
grpc (~> 1.36) grpc (~> 1.36)
gemoji (4.1.0)
geocoder (1.8.1) geocoder (1.8.1)
gli (2.22.2) gli (2.22.2)
ostruct ostruct
@@ -677,14 +676,14 @@ GEM
method_source (~> 1.0) method_source (~> 1.0)
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (7.0.5) public_suffix (6.0.2)
puma (6.4.3) puma (6.4.3)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.6) rack (3.2.5)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.5.0) rack-contrib (2.5.0)
@@ -699,7 +698,7 @@ GEM
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.1.2) rack-session (2.1.1)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.1.0) rack-test (2.1.0)
@@ -1076,7 +1075,6 @@ DEPENDENCIES
fcm fcm
flag_shih_tzu flag_shih_tzu
foreman foreman
gemoji
geocoder geocoder
gmail_xoauth gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0) google-cloud-dialogflow-v2 (>= 0.24.0)
@@ -1,7 +1,6 @@
class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Accounts::BaseController
before_action :fetch_custom_attributes_definitions, except: [:create] before_action :fetch_custom_attributes_definitions, except: [:create]
before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy] before_action :fetch_custom_attribute_definition, only: [:show, :update, :destroy]
before_action :check_authorization
DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze DEFAULT_ATTRIBUTE_MODEL = 'conversation_attribute'.freeze
def index; end def index; end
@@ -18,16 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
label_title = @label.title
account_id = Current.account.id
label_deleted_at = Time.current
@label.destroy! @label.destroy!
Labels::RemoveAssociationsJob.perform_later(
label_title: label_title,
account_id: account_id,
label_deleted_at: label_deleted_at
)
head :ok head :ok
end end
@@ -2,10 +2,6 @@
# This concern handles the token verification step. # This concern handles the token verification step.
module MetaTokenVerifyConcern module MetaTokenVerifyConcern
CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
META_SIGNATURE_HEADER = 'X-Hub-Signature-256'.freeze
META_SIGNATURE_PREFIX = 'sha256='.freeze
def verify def verify
service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram' service = is_a?(Webhooks::WhatsappController) ? 'whatsapp' : 'instagram'
if valid_token?(params['hub.verify_token']) if valid_token?(params['hub.verify_token'])
@@ -18,53 +14,6 @@ module MetaTokenVerifyConcern
private private
def verify_meta_signature!
return unless meta_signature_verification_required?
return if valid_meta_signature?
head :unauthorized
end
def valid_meta_signature?
signature = request.headers[META_SIGNATURE_HEADER]
return false unless signature&.start_with?(META_SIGNATURE_PREFIX)
meta_app_secrets.any? do |secret|
next false if secret.blank?
expected_signature = "#{META_SIGNATURE_PREFIX}#{OpenSSL::HMAC.hexdigest('SHA256', secret, meta_request_body)}"
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature)
end
end
def meta_request_body
@meta_request_body ||= request.raw_post
end
def meta_app_secrets
raise 'Overwrite this method in your controller'
end
def meta_signature_verification_required?
true
end
def channel_meta_app_secrets(channel)
return [] if channel.blank?
secrets = []
secrets << channel.app_secret if channel.respond_to?(:app_secret)
secrets.concat(provider_config_meta_app_secrets(channel))
secrets.compact_blank.uniq
end
def provider_config_meta_app_secrets(channel)
return [] unless channel.respond_to?(:provider_config)
provider_config = channel.provider_config.to_h.with_indifferent_access
CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
end
def valid_token?(_token) def valid_token?(_token)
raise 'Overwrite this method your controller' raise 'Overwrite this method your controller'
end end
+1 -8
View File
@@ -80,17 +80,10 @@ class DashboardController < ActionController::Base
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH, GIT_SHA: GIT_HASH,
ALLOWED_LOGIN_METHODS: allowed_login_methods, ALLOWED_LOGIN_METHODS: allowed_login_methods
ACTIVE_PLATFORM_BANNERS: active_platform_banners
} }
end end
def active_platform_banners
return [] unless ChatwootApp.chatwoot_cloud?
PlatformBanner.active.order(created_at: :desc).as_json(only: %i[id banner_message banner_type updated_at])
end
def allowed_login_methods def allowed_login_methods
methods = ['email'] methods = ['email']
methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false'
@@ -25,14 +25,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
private private
def render_create_error_not_confirmed
render_error(
:unauthorized,
I18n.t('devise_token_auth.sessions.not_confirmed', email: @resource.email),
error_code: 'user_not_confirmed'
)
end
def find_user_for_authentication def find_user_for_authentication
return nil unless params[:email].present? && params[:password].present? return nil unless params[:email].present? && params[:password].present?
@@ -1,9 +0,0 @@
class SuperAdmin::PlatformBannersController < SuperAdmin::ApplicationController
before_action :ensure_chatwoot_cloud
private
def ensure_chatwoot_cloud
raise ActionController::RoutingError, 'Not Found' unless ChatwootApp.chatwoot_cloud?
end
end
@@ -1,8 +1,6 @@
class Webhooks::InstagramController < ActionController::API class Webhooks::InstagramController < ActionController::API
include MetaTokenVerifyConcern include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :events
def events def events
Rails.logger.info('Instagram webhook received events') Rails.logger.info('Instagram webhook received events')
if params['object'].casecmp('instagram').zero? if params['object'].casecmp('instagram').zero?
@@ -41,38 +39,4 @@ class Webhooks::InstagramController < ActionController::API
token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') || token == GlobalConfigService.load('IG_VERIFY_TOKEN', '') ||
token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '') token == GlobalConfigService.load('INSTAGRAM_VERIFY_TOKEN', '')
end end
def meta_app_secrets
[
*instagram_channel_meta_app_secrets,
GlobalConfigService.load('INSTAGRAM_APP_SECRET', nil),
GlobalConfigService.load('FB_APP_SECRET', nil)
]
end
def instagram_channel_meta_app_secrets
instagram_channels_from_payload.flat_map { |channel| channel_meta_app_secrets(channel) }
end
def instagram_channels_from_payload
Array(params.to_unsafe_hash[:entry]).flat_map do |entry|
instagram_ids_from_entry(entry.with_indifferent_access).flat_map do |instagram_id|
[
Channel::Instagram.find_by(instagram_id: instagram_id),
Channel::FacebookPage.find_by(instagram_id: instagram_id)
]
end
end.compact.uniq
end
def instagram_ids_from_entry(entry)
messages = entry[:messaging].presence || entry[:standby] || []
messages.filter_map { |messaging| instagram_id_from_messaging(messaging.with_indifferent_access) }
end
def instagram_id_from_messaging(messaging)
return messaging.dig(:sender, :id) if messaging.dig(:message, :is_echo).present?
messaging.dig(:recipient, :id)
end
end end
@@ -1,8 +1,6 @@
class Webhooks::WhatsappController < ActionController::API class Webhooks::WhatsappController < ActionController::API
include MetaTokenVerifyConcern include MetaTokenVerifyConcern
before_action :verify_meta_signature!, only: :process_payload
def process_payload def process_payload
if inactive_whatsapp_number? if inactive_whatsapp_number?
Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}") Rails.logger.warn("Rejected webhook for inactive WhatsApp number: #{params[:phone_number]}")
@@ -22,45 +20,6 @@ class Webhooks::WhatsappController < ActionController::API
token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present? token == whatsapp_webhook_verify_token if whatsapp_webhook_verify_token.present?
end end
def meta_app_secrets
[
*channel_meta_app_secrets(whatsapp_channel),
GlobalConfigService.load('WHATSAPP_APP_SECRET', nil)
]
end
def whatsapp_channel
@whatsapp_channel ||= whatsapp_business_payload_channel || Channel::Whatsapp.find_by(phone_number: params[:phone_number])
end
def meta_signature_verification_required?
return true if whatsapp_channel.blank?
return false unless whatsapp_channel.provider == 'whatsapp_cloud'
return true if channel_meta_app_secrets(whatsapp_channel).present?
whatsapp_channel.provider_config['source'] == 'embedded_signup'
end
def whatsapp_business_payload_channel
return unless params[:object] == 'whatsapp_business_account'
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
return if metadata.blank?
phone_number = normalized_phone_number(metadata[:display_phone_number])
phone_number_id = metadata[:phone_number_id]
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
end
def normalized_phone_number(phone_number)
return if phone_number.blank?
phone_number = phone_number.to_s
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
end
def inactive_whatsapp_number? def inactive_whatsapp_number?
phone_number = params[:phone_number] phone_number = params[:phone_number]
return false if phone_number.blank? return false if phone_number.blank?
@@ -1,20 +0,0 @@
require 'administrate/base_dashboard'
class PlatformBannerDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
id: Field::Number,
banner_message: Field::Text.with_options(truncate: 200),
banner_type: Field::Select.with_options(collection: %w[info warning error]),
active: Field::Boolean,
created_at: Field::DateTime,
updated_at: Field::DateTime
}.freeze
COLLECTION_ATTRIBUTES = %i[id banner_message banner_type active created_at].freeze
SHOW_PAGE_ATTRIBUTES = %i[id banner_message banner_type active created_at updated_at].freeze
FORM_ATTRIBUTES = %i[banner_message banner_type active].freeze
def display_resource(platform_banner)
"Banner ##{platform_banner.id} (#{platform_banner.banner_type})"
end
end
+2 -6
View File
@@ -7,15 +7,11 @@ class UserDrop < BaseDrop
@obj.try(:available_name) @obj.try(:available_name)
end end
def email
@obj.try(:email)
end
def first_name def first_name
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 @obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
def last_name def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size).to_i > 1 @obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end end
end end
-2
View File
@@ -48,5 +48,3 @@ class MessageFinder
messages.reorder('created_at desc').limit(20).reverse messages.reorder('created_at desc').limit(20).reverse
end end
end end
MessageFinder.prepend_mod_with('MessageFinder')
+10 -18
View File
@@ -17,12 +17,15 @@ module Api::V1::InboxesHelper
def validate_imap(channel_data) def validate_imap(channel_data)
return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled] return unless channel_data.key?('imap_enabled') && channel_data[:imap_enabled]
# Validate the user-selected auth mechanism before opening the connection. Mail.defaults do
authentication = Imap::Authentication.validate_user_configurable!(channel_data[:imap_authentication]) retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
# Use the same auth adapter as the fetch service so LOGIN uses the IMAP LOGIN command, check_imap_connection(channel_data)
# not SASL AUTH=LOGIN.
check_imap_connection(channel_data, authentication)
end end
def validate_smtp(channel_data) def validate_smtp(channel_data)
@@ -34,8 +37,8 @@ module Api::V1::InboxesHelper
check_smtp_connection(channel_data, smtp) check_smtp_connection(channel_data, smtp)
end end
def check_imap_connection(channel_data, authentication) def check_imap_connection(channel_data)
imap = open_imap_connection(channel_data, authentication) Mail.connection {} # rubocop:disable:block
rescue SocketError => e rescue SocketError => e
raise StandardError, I18n.t('errors.inboxes.imap.socket_error') raise StandardError, I18n.t('errors.inboxes.imap.socket_error')
rescue Net::IMAP::NoResponseError => e rescue Net::IMAP::NoResponseError => e
@@ -50,20 +53,9 @@ module Api::V1::InboxesHelper
rescue StandardError => e rescue StandardError => e
raise StandardError, e.message raise StandardError, e.message
ensure ensure
imap.disconnect if imap.present? && !imap.disconnected?
Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present? Rails.logger.error "[Api::V1::InboxesHelper] check_imap_connection failed with #{e.message}" if e.present?
end end
def open_imap_connection(channel_data, authentication)
imap = build_imap_connection(channel_data)
Imap::Authentication.authenticate!(imap, authentication, channel_data[:imap_login], channel_data[:imap_password])
imap
end
def build_imap_connection(channel_data)
Net::IMAP.new(channel_data[:imap_address], port: channel_data[:imap_port], ssl: channel_data[:imap_enable_ssl])
end
def check_smtp_connection(channel_data, smtp) def check_smtp_connection(channel_data, smtp)
smtp.open_timeout = 10 smtp.open_timeout = 10
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password], smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
-3
View File
@@ -3,7 +3,6 @@ import { mapGetters } from 'vuex';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import StatusBanner from './components/app/StatusBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue'; import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue'; import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable'; import vueActionCable from './helper/actionCable';
@@ -28,7 +27,6 @@ export default {
LoadingState, LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
StatusBanner,
PaymentPendingBanner, PaymentPendingBanner,
WootSnackbarBox, WootSnackbarBox,
PendingEmailVerificationBanner, PendingEmailVerificationBanner,
@@ -139,7 +137,6 @@ export default {
:dir="isRTL ? 'rtl' : 'ltr'" :dir="isRTL ? 'rtl' : 'ltr'"
> >
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" /> <UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<StatusBanner />
<template v-if="currentAccountId"> <template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" /> <PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" /> <PaymentPendingBanner v-if="hideOnOnboardingView" />
@@ -68,7 +68,7 @@ class TwilioVoiceClient extends EventTarget {
this.inboxId = null; this.inboxId = null;
} }
async joinClientCall({ to, conversationId, callSid }) { async joinClientCall({ to, conversationId }) {
if (!this.device || !this.initialized || !to) return null; if (!this.device || !this.initialized || !to) return null;
if (this.activeConnection) return this.activeConnection; if (this.activeConnection) return this.activeConnection;
@@ -76,7 +76,6 @@ class TwilioVoiceClient extends EventTarget {
To: to, To: to,
is_agent: 'true', is_agent: 'true',
conversation_id: conversationId, conversation_id: conversationId,
call_sid: callSid,
}; };
const connection = await this.device.connect({ params }); const connection = await this.device.connect({ params });
@@ -12,10 +12,10 @@ class VoiceAPI extends ApiClient {
return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data); return ContactsAPI.initiateCall(contactId, inboxId).then(r => r.data);
} }
leaveConference({ inboxId, conversationId, callSid }) { leaveConference(inboxId, conversationId) {
return axios return axios
.delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, { .delete(`${this.baseUrl()}/inboxes/${inboxId}/conference`, {
params: { conversation_id: conversationId, call_sid: callSid }, params: { conversation_id: conversationId },
}) })
.then(r => r.data); .then(r => r.data);
} }
+17 -35
View File
@@ -1,12 +1,21 @@
/* global axios */ /* global axios */
import ApiClient from './ApiClient'; import ApiClient from './ApiClient';
const buildParams = params => export const buildCompanyParams = (page, sort) => {
new URLSearchParams( let params = `page=${page}`;
Object.entries(params).filter( if (sort) {
([key, value]) => value !== undefined && (value !== '' || key === 'q') params = `${params}&sort=${sort}`;
) }
).toString(); return params;
};
export const buildSearchParams = (query, page, sort) => {
let params = `q=${encodeURIComponent(query)}&page=${page}`;
if (sort) {
params = `${params}&sort=${sort}`;
}
return params;
};
class CompanyAPI extends ApiClient { class CompanyAPI extends ApiClient {
constructor() { constructor() {
@@ -15,41 +24,14 @@ class CompanyAPI extends ApiClient {
get(params = {}) { get(params = {}) {
const { page = 1, sort = 'name' } = params; const { page = 1, sort = 'name' } = params;
const requestURL = `${this.url}?${buildParams({ page, sort })}`; const requestURL = `${this.url}?${buildCompanyParams(page, sort)}`;
return axios.get(requestURL); return axios.get(requestURL);
} }
search(query = '', page = 1, sort = 'name') { search(query = '', page = 1, sort = 'name') {
const requestURL = `${this.url}/search?${buildParams({ q: query, page, sort })}`; const requestURL = `${this.url}/search?${buildSearchParams(query, page, sort)}`;
return axios.get(requestURL); return axios.get(requestURL);
} }
listContacts(id, page = 1) {
return axios.get(`${this.url}/${id}/contacts?${buildParams({ page })}`);
}
searchContacts(id, query = '', page = 1) {
const requestURL = `${this.url}/${id}/contacts/search?${buildParams({ q: query, page })}`;
return axios.get(requestURL);
}
createContact(id, payload) {
return axios.post(`${this.url}/${id}/contacts`, payload);
}
removeContact(id, contactId) {
return axios.delete(`${this.url}/${id}/contacts/${contactId}`);
}
destroyCustomAttributes(id, customAttributes) {
return axios.post(`${this.url}/${id}/destroy_custom_attributes`, {
custom_attributes: customAttributes,
});
}
destroyAvatar(id) {
return axios.delete(`${this.url}/${id}/avatar`);
}
} }
export default new CompanyAPI(); export default new CompanyAPI();
@@ -1,4 +1,7 @@
import companyAPI from '../companies'; import companyAPI, {
buildCompanyParams,
buildSearchParams,
} from '../companies';
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
describe('#CompanyAPI', () => { describe('#CompanyAPI', () => {
@@ -6,6 +9,7 @@ describe('#CompanyAPI', () => {
expect(companyAPI).toBeInstanceOf(ApiClient); expect(companyAPI).toBeInstanceOf(ApiClient);
expect(companyAPI).toHaveProperty('get'); expect(companyAPI).toHaveProperty('get');
expect(companyAPI).toHaveProperty('show'); expect(companyAPI).toHaveProperty('show');
expect(companyAPI).toHaveProperty('create');
expect(companyAPI).toHaveProperty('update'); expect(companyAPI).toHaveProperty('update');
expect(companyAPI).toHaveProperty('delete'); expect(companyAPI).toHaveProperty('delete');
expect(companyAPI).toHaveProperty('search'); expect(companyAPI).toHaveProperty('search');
@@ -28,69 +32,111 @@ describe('#CompanyAPI', () => {
window.axios = originalAxios; window.axios = originalAxios;
}); });
it('#get includes pagination and sorting params', () => { it('#get with default params', () => {
companyAPI.get({}); companyAPI.get({});
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=name' '/api/v1/companies?page=1&sort=name'
); );
}); });
it('#search encodes query params', () => { it('#get with page and sort params', () => {
companyAPI.search('acme & co', 2, 'domain'); companyAPI.get({ page: 2, sort: 'domain' });
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme+%26+co&page=2&sort=domain' '/api/v1/companies?page=2&sort=domain'
); );
}); });
it('#search keeps empty query param for backend validation', () => { it('#get with descending sort', () => {
companyAPI.get({ page: 1, sort: '-created_at' });
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies?page=1&sort=-created_at'
);
});
it('#search with query', () => {
companyAPI.search('acme', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme&page=1&sort=name'
);
});
it('#search with special characters in query', () => {
companyAPI.search('acme & co', 2, 'domain');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=acme%20%26%20co&page=2&sort=domain'
);
});
it('#search with descending sort', () => {
companyAPI.search('test', 1, '-created_at');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=test&page=1&sort=-created_at'
);
});
it('#search with empty query', () => {
companyAPI.search('', 1, 'name'); companyAPI.search('', 1, 'name');
expect(axiosMock.get).toHaveBeenCalledWith( expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/companies/search?q=&page=1&sort=name' '/api/v1/companies/search?q=&page=1&sort=name'
); );
}); });
});
});
it('#destroyAvatar deletes the company avatar endpoint', () => { describe('#buildCompanyParams', () => {
companyAPI.destroyAvatar(1); it('returns correct string with page only', () => {
expect(axiosMock.delete).toHaveBeenCalledWith( expect(buildCompanyParams(1)).toBe('page=1');
'/api/v1/companies/1/avatar' });
);
}); it('returns correct string with page and sort', () => {
expect(buildCompanyParams(1, 'name')).toBe('page=1&sort=name');
it('#listContacts fetches company contacts', () => { });
companyAPI.listContacts(1, 2);
expect(axiosMock.get).toHaveBeenCalledWith( it('returns correct string with different page', () => {
'/api/v1/companies/1/contacts?page=2' expect(buildCompanyParams(3, 'domain')).toBe('page=3&sort=domain');
); });
});
it('returns correct string with descending sort', () => {
it('#searchContacts encodes contact search params', () => { expect(buildCompanyParams(1, '-created_at')).toBe(
companyAPI.searchContacts(1, 'jane & co', 3); 'page=1&sort=-created_at'
expect(axiosMock.get).toHaveBeenCalledWith( );
'/api/v1/companies/1/contacts/search?q=jane+%26+co&page=3' });
);
}); it('returns correct string without sort parameter', () => {
expect(buildCompanyParams(2, '')).toBe('page=2');
it('#createContact links a contact to the company', () => { });
companyAPI.createContact(1, { contact_id: 2 }); });
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/companies/1/contacts', describe('#buildSearchParams', () => {
{ contact_id: 2 } it('returns correct string with all parameters', () => {
); expect(buildSearchParams('acme', 1, 'name')).toBe(
}); 'q=acme&page=1&sort=name'
);
it('#removeContact unlinks a contact from the company', () => { });
companyAPI.removeContact(1, 2);
expect(axiosMock.delete).toHaveBeenCalledWith( it('returns correct string with special characters', () => {
'/api/v1/companies/1/contacts/2' expect(buildSearchParams('acme & co', 2, 'domain')).toBe(
); 'q=acme%20%26%20co&page=2&sort=domain'
}); );
});
it('#destroyCustomAttributes removes company custom attributes', () => {
companyAPI.destroyCustomAttributes(1, ['plan']); it('returns correct string with empty query', () => {
expect(axiosMock.post).toHaveBeenCalledWith( expect(buildSearchParams('', 1, 'name')).toBe('q=&page=1&sort=name');
'/api/v1/companies/1/destroy_custom_attributes', });
{ custom_attributes: ['plan'] }
); it('returns correct string without sort parameter', () => {
}); expect(buildSearchParams('test', 1, '')).toBe('q=test&page=1');
});
it('returns correct string with descending sort', () => {
expect(buildSearchParams('company', 3, '-created_at')).toBe(
'q=company&page=3&sort=-created_at'
);
});
it('encodes special characters correctly', () => {
expect(buildSearchParams('test@example.com', 1, 'name')).toBe(
'q=test%40example.com&page=1&sort=name'
);
}); });
}); });
@@ -1,7 +1,7 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper'; import { formatDistanceToNow } from 'date-fns';
import CardLayout from 'dashboard/components-next/CardLayout.vue'; import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -12,8 +12,9 @@ const props = defineProps({
name: { type: String, default: '' }, name: { type: String, default: '' },
domain: { type: String, default: '' }, domain: { type: String, default: '' },
contactsCount: { type: Number, default: 0 }, contactsCount: { type: Number, default: 0 },
description: { type: String, default: '' },
avatarUrl: { type: String, default: '' }, avatarUrl: { type: String, default: '' },
lastActivityAt: { type: [String, Number], default: null }, updatedAt: { type: [String, Number], default: null },
}); });
const emit = defineEmits(['showCompany']); const emit = defineEmits(['showCompany']);
@@ -26,21 +27,15 @@ const displayName = computed(() => props.name || t('COMPANIES.UNNAMED'));
const avatarSource = computed(() => props.avatarUrl || null); const avatarSource = computed(() => props.avatarUrl || null);
const hasContacts = computed(() => Number(props.contactsCount || 0) > 0); const formattedUpdatedAt = computed(() => {
if (!props.updatedAt) return '';
const contactsCountLabel = computed(() => return formatDistanceToNow(new Date(props.updatedAt), { addSuffix: true });
t('COMPANIES.CONTACTS_COUNT', { n: Number(props.contactsCount || 0) })
);
const formattedLastActivityAt = computed(() => {
if (!props.lastActivityAt) return '';
return dynamicTime(props.lastActivityAt);
}); });
</script> </script>
<template> <template>
<CardLayout layout="row" @click="onClickViewDetails"> <CardLayout layout="row" @click="onClickViewDetails">
<div class="flex items-center justify-start flex-1 gap-4 cursor-pointer"> <div class="flex items-center justify-start flex-1 gap-4">
<Avatar <Avatar
:username="displayName" :username="displayName"
:src="avatarSource" :src="avatarSource"
@@ -56,29 +51,42 @@ const formattedLastActivityAt = computed(() => {
{{ displayName }} {{ displayName }}
</span> </span>
<span <span
v-if="hasContacts" v-if="domain && description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate" class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
> >
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" /> <Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
{{ contactsCountLabel }} <span class="truncate">{{ domain }}</span>
</span> </span>
</div> </div>
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between">
<div class="flex items-center min-w-0"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
<span <span
v-if="domain" v-if="domain && !description"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate cursor-text" class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
@click.stop
> >
<Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" /> <Icon icon="i-lucide-globe" size="size-3.5 text-n-slate-11" />
<span class="truncate">{{ domain }}</span> <span class="truncate">{{ domain }}</span>
</span> </span>
<span v-if="description" class="text-sm text-n-slate-11 truncate">
{{ description }}
</span>
<div
v-if="(description || domain) && contactsCount"
class="w-px h-3 bg-n-slate-6"
/>
<span
v-if="contactsCount"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 truncate"
>
<Icon icon="i-lucide-contact" size="size-3.5 text-n-slate-11" />
{{ t('COMPANIES.CONTACTS_COUNT', { n: contactsCount }) }}
</span>
</div> </div>
<span <span
v-if="lastActivityAt" v-if="updatedAt"
class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0" class="inline-flex items-center gap-1.5 text-sm text-n-slate-11 flex-shrink-0"
> >
{{ formattedLastActivityAt }} {{ formattedUpdatedAt }}
</span> </span>
</div> </div>
</div> </div>
@@ -1,115 +0,0 @@
<script setup>
import { ref, useSlots } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
breadcrumbItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['back']);
const slots = useSlots();
const isSidebarOpen = ref(false);
const toggleSidebar = () => {
isSidebarOpen.value = !isSidebarOpen.value;
};
const closeMobileSidebar = () => {
if (!isSidebarOpen.value) {
return;
}
isSidebarOpen.value = false;
};
</script>
<template>
<section
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
>
<div
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-[40.625rem]">
<div
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
>
<Breadcrumb :items="breadcrumbItems" @click="emit('back')" />
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
<slot />
</div>
</main>
</div>
<div
v-if="slots.sidebar"
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>
<div
v-if="slots.sidebar"
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
:class="isSidebarOpen ? 'w-full' : 'w-16'"
>
<div
v-on-click-outside="[
closeMobileSidebar,
{ ignore: ['#details-sidebar-content'] },
]"
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
:class="[
isSidebarOpen
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
]"
>
<Button
ghost
slate
sm
class="!rounded-full rtl:rotate-180"
:class="{ 'bg-n-alpha-2': isSidebarOpen }"
:icon="
isSidebarOpen
? 'i-lucide-panel-right-close'
: 'i-lucide-panel-right-open'
"
data-details-sidebar-toggle
@click="toggleSidebar"
/>
</div>
<Transition
enter-active-class="transition-transform duration-200 ease-in-out"
leave-active-class="transition-transform duration-200 ease-in-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
>
<div
v-if="isSidebarOpen"
id="details-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<slot name="sidebar" />
</div>
</Transition>
</div>
</section>
</template>
@@ -2,7 +2,6 @@
import Input from 'dashboard/components-next/input/Input.vue'; import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import CompanySortMenu from './components/CompanySortMenu.vue'; import CompanySortMenu from './components/CompanySortMenu.vue';
import CompanyMoreActions from './components/CompanyMoreActions.vue';
defineProps({ defineProps({
showSearch: { type: Boolean, default: true }, showSearch: { type: Boolean, default: true },
@@ -12,7 +11,7 @@ defineProps({
activeOrdering: { type: String, default: '' }, activeOrdering: { type: String, default: '' },
}); });
const emit = defineEmits(['search', 'update:sort', 'create']); const emit = defineEmits(['search', 'update:sort']);
</script> </script>
<template> <template>
@@ -20,15 +19,22 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
<div <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 gap-2 mx-auto max-w-5xl"
> >
<span class="text-xl font-medium truncate text-n-slate-12"> <span class="text-heading-1 truncate text-n-slate-12">
{{ headerTitle }} {{ headerTitle }}
</span> </span>
<div class="flex items-center flex-col sm:flex-row flex-shrink-0 gap-4"> <div class="flex items-center flex-row flex-shrink-0 gap-2">
<div class="flex items-center">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
</div>
<div v-if="showSearch" class="flex items-center gap-2 w-full"> <div v-if="showSearch" class="flex items-center gap-2 w-full">
<Input <Input
:model-value="searchValue" :model-value="searchValue"
type="search" type="search"
:placeholder="$t('COMPANIES.SEARCH_PLACEHOLDER')" :placeholder="$t('CONTACTS_LAYOUT.HEADER.SEARCH_PLACEHOLDER')"
:custom-input-class="[ :custom-input-class="[
'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8', 'h-8 [&:not(.focus)]:!border-transparent bg-n-alpha-2 dark:bg-n-solid-1 ltr:!pl-8 !py-1 rtl:!pr-8',
]" ]"
@@ -43,14 +49,6 @@ const emit = defineEmits(['search', 'update:sort', 'create']);
</template> </template>
</Input> </Input>
</div> </div>
<div class="flex items-center flex-shrink-0 gap-2">
<CompanySortMenu
:active-sort="activeSort"
:active-ordering="activeOrdering"
@update:sort="emit('update:sort', $event)"
/>
<CompanyMoreActions @create="emit('create')" />
</div>
</div> </div>
</div> </div>
</header> </header>
@@ -1,45 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['create']);
const { t } = useI18n();
const showActionsDropdown = ref(false);
const menuItems = [
{
label: t('COMPANIES.ACTIONS.CREATE'),
action: 'create',
value: 'create',
icon: 'i-lucide-plus',
},
];
const handleAction = ({ action }) => {
if (action === 'create') emit('create');
showActionsDropdown.value = false;
};
</script>
<template>
<div v-on-clickaway="() => (showActionsDropdown = false)" class="relative">
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
variant="ghost"
size="sm"
:class="showActionsDropdown ? 'bg-n-alpha-2' : ''"
@click="showActionsDropdown = !showActionsDropdown"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -35,10 +35,6 @@ const sortMenus = [
label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'), label: t('COMPANIES.SORT_BY.OPTIONS.CREATED_AT'),
value: 'created_at', value: 'created_at',
}, },
{
label: t('COMPANIES.SORT_BY.OPTIONS.LAST_ACTIVITY_AT'),
value: 'last_activity_at',
},
{ {
label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'), label: t('COMPANIES.SORT_BY.OPTIONS.CONTACTS_COUNT'),
value: 'contacts_count', value: 'contacts_count',
@@ -105,7 +101,6 @@ const handleOrderChange = value => {
:model-value="activeSort" :model-value="activeSort"
:options="sortMenus" :options="sortMenus"
:label="activeSortLabel" :label="activeSortLabel"
sub-menu-position="left"
@update:model-value="handleSortChange" @update:model-value="handleSortChange"
/> />
</div> </div>
@@ -117,7 +112,6 @@ const handleOrderChange = value => {
:model-value="activeOrdering" :model-value="activeOrdering"
:options="orderingMenus" :options="orderingMenus"
:label="activeOrderingLabel" :label="activeOrderingLabel"
sub-menu-position="left"
@update:model-value="handleOrderChange" @update:model-value="handleOrderChange"
/> />
</div> </div>
@@ -12,12 +12,7 @@ defineProps({
showPaginationFooter: { type: Boolean, default: true }, showPaginationFooter: { type: Boolean, default: true },
}); });
const emit = defineEmits([ const emit = defineEmits(['update:currentPage', 'update:sort', 'search']);
'update:currentPage',
'update:sort',
'search',
'create',
]);
const updateCurrentPage = page => { const updateCurrentPage = page => {
emit('update:currentPage', page); emit('update:currentPage', page);
@@ -36,7 +31,6 @@ const updateCurrentPage = page => {
:active-ordering="activeOrdering" :active-ordering="activeOrdering"
@search="emit('search', $event)" @search="emit('search', $event)"
@update:sort="emit('update:sort', $event)" @update:sort="emit('update:sort', $event)"
@create="emit('create')"
/> />
<main class="flex-1 px-6 overflow-y-auto"> <main class="flex-1 px-6 overflow-y-auto">
<div class="w-full mx-auto max-w-5xl py-4"> <div class="w-full mx-auto max-w-5xl py-4">
@@ -1,110 +0,0 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isLoading: { type: Boolean, default: false },
});
const emit = defineEmits(['create']);
const { t } = useI18n();
const dialogRef = ref(null);
const form = reactive({ name: '', domain: '', description: '' });
const isFormInvalid = computed(() => !form.name.trim());
const resetForm = () => {
form.name = '';
form.domain = '';
form.description = '';
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
emit('create', {
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
};
const closeDialog = () => {
dialogRef.value?.close();
};
const onSuccess = () => {
resetForm();
closeDialog();
};
defineExpose({ dialogRef, onSuccess });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
overflow-y-auto
@confirm="handleConfirm"
@close="resetForm"
>
<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('COMPANIES.CREATE.TITLE') }}
</span>
<div class="grid w-full grid-cols-1 gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
autofocus
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isLoading"
custom-input-class="h-8 !pt-1 !pb-1 [&:not(.error,.focus)]:!outline-transparent"
/>
</div>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isLoading"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
</div>
<template #footer>
<div class="flex items-center justify-between w-full gap-3">
<Button
:label="t('DIALOG.BUTTONS.CANCEL')"
variant="link"
type="reset"
class="h-10 hover:!no-underline hover:text-n-brand"
@click="closeDialog"
/>
<Button
:label="t('COMPANIES.CREATE.ACTIONS.SAVE')"
color="blue"
type="submit"
:disabled="isFormInvalid || isLoading"
:is-loading="isLoading"
/>
</div>
</template>
</Dialog>
</template>
@@ -1,354 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { debounce } from '@chatwoot/utils';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
contacts: {
type: Array,
default: () => [],
},
meta: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
isBusy: {
type: Boolean,
default: false,
},
searchResults: {
type: Array,
default: () => [],
},
isSearching: {
type: Boolean,
default: false,
},
selectedContact: {
type: Object,
default: null,
},
});
const emit = defineEmits([
'cancelContactSelection',
'confirmContactSelection',
'removeContact',
'search',
'selectContact',
'update:currentPage',
]);
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const selectedContactId = ref(null);
const searchQuery = ref('');
const hasContacts = computed(() => props.contacts.length > 0);
const currentPage = computed(() => Number(props.meta?.page || 1));
const totalContacts = computed(() => Number(props.meta?.totalCount || 0));
const linkedContactIds = computed(
() => new Set(props.contacts.map(contact => Number(contact.id)))
);
const showPaginationFooter = computed(
() => hasContacts.value && totalContacts.value > props.contacts.length
);
const openContact = contactId => {
router.push({
name: 'contacts_edit',
params: {
accountId: route.params.accountId,
contactId,
},
});
};
const contactMeta = contact =>
[contact.email, contact.phoneNumber].filter(Boolean).join(' • ');
const contactName = contact =>
contact.name || t('COMPANIES.DETAIL.CONTACTS.UNNAMED_CONTACT');
const contactOptions = computed(() =>
props.searchResults
.filter(
contact =>
!contact.linkedToCurrentCompany &&
!linkedContactIds.value.has(Number(contact.id))
)
.map(contact => ({
value: contact.id,
label: [contactName(contact), contact.email, contact.phoneNumber]
.filter(Boolean)
.join(' · '),
}))
);
const emptyState = computed(() => {
if (props.isSearching) {
return t('COMPANIES.DETAIL.CONTACTS.LOADING');
}
return searchQuery.value.trim()
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.EMPTY')
: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.INITIAL');
});
const selectedContactName = computed(() =>
props.selectedContact ? contactName(props.selectedContact) : ''
);
const selectedContactMeta = computed(() =>
props.selectedContact ? contactMeta(props.selectedContact) : ''
);
const selectedContactCompanyName = computed(
() => props.selectedContact?.company?.name || ''
);
const summaryRows = computed(() => [
{
key: 'company',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.COMPANY_LABEL'),
avatarName: props.company.name || t('COMPANIES.UNNAMED'),
avatarSrc: props.company.avatarUrl,
primary: props.company.name || t('COMPANIES.UNNAMED'),
secondary: props.company.domain,
},
{
key: 'contact',
label: t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONTACT_LABEL'),
badge: selectedContactCompanyName.value
? t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CURRENT_COMPANY', {
companyName: selectedContactCompanyName.value,
})
: '',
avatarName: selectedContactName.value,
avatarSrc: props.selectedContact?.thumbnail,
primary: selectedContactName.value,
secondary: selectedContactMeta.value,
},
]);
const debouncedSearch = debounce(query => {
emit('search', query);
}, 300);
const handleSearch = query => {
searchQuery.value = query;
debouncedSearch(query.trim());
};
const handleContactSelect = contactId => {
const selectedContact = props.searchResults.find(
contact => contact.id === Number(contactId)
);
selectedContactId.value = null;
if (selectedContact) {
emit('selectContact', selectedContact);
}
};
</script>
<template>
<div class="flex flex-col gap-6 px-6 pb-8 pt-1">
<div v-if="!selectedContact" class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.DESCRIPTION') }}
</span>
</div>
<ComboBox
use-api-results
:model-value="selectedContactId"
:options="contactOptions"
:disabled="isBusy"
:empty-state="emptyState"
:search-placeholder="
t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.SEARCH_PLACEHOLDER')
"
:placeholder="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.ADD')"
class="[&>div>button]:bg-n-alpha-black2"
@search="handleSearch"
@update:model-value="handleContactSelect"
/>
</div>
<div v-else class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<label class="text-base text-n-slate-12">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_TITLE') }}
</label>
<span class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CONFIRM_DESCRIPTION') }}
</span>
</div>
<div class="flex flex-col gap-4">
<div
v-for="row in summaryRows"
:key="row.key"
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">
{{ row.label }}
</label>
<span
v-if="row.badge"
class="px-2 py-0.5 text-xs rounded-md text-n-amber-11 bg-n-alpha-2"
>
{{ row.badge }}
</span>
</div>
<div
class="border border-n-strong h-[60px] gap-2 flex items-center rounded-xl p-3"
>
<Avatar
:name="row.avatarName"
:src="row.avatarSrc"
:size="32"
rounded-full
hide-offline-status
/>
<div class="flex flex-col w-full min-w-0 gap-1">
<span
class="text-sm leading-4 font-medium truncate text-n-slate-12"
>
{{ row.primary }}
</span>
<span
v-if="row.secondary"
class="text-sm leading-4 truncate text-n-slate-11"
>
{{ row.secondary }}
</span>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 mt-2">
<Button
variant="faded"
color="slate"
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
:disabled="isBusy"
@click="emit('cancelContactSelection')"
/>
<Button
:label="t('COMPANIES.DETAIL.CONTACTS.DIALOGS.ADD.ADD')"
class="w-full"
:is-loading="isBusy"
:disabled="isBusy"
@click="emit('confirmContactSelection')"
/>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.SIDEBAR.TABS.CONTACTS') }}
</h4>
<span v-if="hasContacts" class="text-xs tabular-nums text-n-slate-11">
{{ t('COMPANIES.CONTACTS_COUNT', { n: totalContacts }) }}
</span>
</div>
<div
v-if="isLoading && !hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.LOADING') }}
</div>
<div
v-else-if="!hasContacts"
class="py-8 text-sm text-center rounded-xl border border-dashed border-n-weak text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.CONTACTS.EMPTY') }}
</div>
<div v-else class="flex flex-col divide-y divide-n-weak">
<div
v-for="contact in contacts"
:key="contact.id"
class="flex items-center gap-2 py-3 group/contact"
>
<button
type="button"
class="flex items-center flex-1 min-w-0 !p-0 gap-3 text-start rounded-lg transition-colors text-n-slate-12 hover:text-n-blue-11 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-n-brand focus-visible:ring-offset-2 focus-visible:ring-offset-n-background"
@click="openContact(contact.id)"
>
<Avatar
:name="contactName(contact)"
:src="contact.thumbnail"
:size="32"
rounded-full
hide-offline-status
/>
<div class="min-w-0 space-y-0.5">
<span
class="text-sm font-medium leading-5 truncate text-n-slate-12"
>
{{ contactName(contact) }}
</span>
<p
v-if="contactMeta(contact)"
class="text-sm leading-5 truncate text-n-slate-11"
>
{{ contactMeta(contact) }}
</p>
</div>
</button>
<Button
icon="i-lucide-unlink"
color="slate"
variant="ghost"
size="xs"
class="shrink-0 opacity-70 transition-opacity sm:opacity-0 sm:group-hover/contact:opacity-100 sm:focus-visible:opacity-100"
:disabled="isBusy"
:title="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
:aria-label="t('COMPANIES.DETAIL.CONTACTS.ACTIONS.REMOVE')"
@click.stop="emit('removeContact', contact.id)"
/>
</div>
</div>
<PaginationFooter
v-if="showPaginationFooter"
current-page-info="CONTACTS_LAYOUT.PAGINATION_FOOTER.SHOWING"
:current-page="currentPage"
:total-items="totalContacts"
:items-per-page="15"
class="!px-0 before:hidden"
@update:current-page="emit('update:currentPage', $event)"
/>
</div>
</div>
</template>
@@ -1,94 +0,0 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { useCompaniesStore } from 'dashboard/stores/companies';
import ListAttribute from 'dashboard/components-next/CustomAttributes/ListAttribute.vue';
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';
const props = defineProps({
companyId: {
type: Number,
required: true,
},
attribute: {
type: Object,
required: true,
},
isEditingView: {
type: Boolean,
default: false,
},
});
const companiesStore = useCompaniesStore();
const { t } = useI18n();
const handleDelete = async () => {
try {
await companiesStore.deleteCustomAttributes({
id: props.companyId,
customAttributes: [props.attribute.attributeKey],
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.DELETE_ERROR')
);
}
};
const handleUpdate = async value => {
try {
await companiesStore.update({
id: props.companyId,
customAttributes: {
[props.attribute.attributeKey]: value,
},
});
useAlert(t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_SUCCESS'));
} catch (error) {
useAlert(
error?.response?.message ||
t('COMPANIES.DETAIL.ATTRIBUTES.MESSAGES.UPDATE_ERROR')
);
}
};
const componentMap = {
list: ListAttribute,
checkbox: CheckboxAttribute,
date: DateAttribute,
default: OtherAttribute,
};
const CurrentAttributeComponent = computed(
() =>
componentMap[props.attribute.attributeDisplayType] || componentMap.default
);
</script>
<template>
<div
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 justify-between truncate">
<span class="text-sm font-medium truncate text-n-slate-12">
{{ attribute.attributeDisplayName }}
</span>
</div>
<component
:is="CurrentAttributeComponent"
:attribute="attribute"
:is-editing-view="isEditingView"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
</template>
@@ -1,142 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import CompanyCustomAttributeItem from 'dashboard/components-next/Companies/CompanyDetail/CompanyCustomAttributeItem.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const searchQuery = ref('');
const companyAttributes = useMapGetter('attributes/getCompanyAttributes');
const customAttributes = computed(() => props.company?.customAttributes || {});
const hasCompanyAttributes = computed(
() => companyAttributes.value?.length > 0
);
const processCompanyAttributes = (
attributes,
attributeValues,
filterCondition
) => {
if (!attributes.length) {
return [];
}
return attributes.reduce((result, attribute) => {
const { attributeKey } = attribute;
if (filterCondition(attributeKey, attributeValues)) {
result.push({
...attribute,
value: attributeValues[attributeKey] ?? '',
});
}
return result;
}, []);
};
const usedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => key in values
)
);
const unusedAttributes = computed(() =>
processCompanyAttributes(
companyAttributes.value,
customAttributes.value,
(key, values) => !(key in values)
)
);
const filteredUnusedAttributes = computed(() =>
unusedAttributes.value.filter(attribute =>
attribute.attributeDisplayName
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
)
);
const unusedAttributesCount = computed(() => unusedAttributes.value.length);
const hasNoUnusedAttributes = computed(() => unusedAttributesCount.value === 0);
const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
onMounted(() => {
store.dispatch('attributes/get');
});
</script>
<template>
<div v-if="hasCompanyAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in usedAttributes"
:key="`${company.id}-${attribute.id}`"
is-editing-view
:company-id="company.id"
:attribute="attribute"
/>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
<div class="flex-1 h-px bg-n-slate-5" />
<span class="text-sm font-medium text-n-slate-10">
{{
t('COMPANIES.DETAIL.ATTRIBUTES.UNUSED_ATTRIBUTES', {
count: unusedAttributesCount,
})
}}
</span>
<div class="flex-1 h-px bg-n-slate-5" />
</div>
<div class="flex flex-col gap-3">
<div v-if="!hasNoUnusedAttributes" class="relative">
<span
class="absolute i-lucide-search size-3.5 top-2 ltr:left-3 rtl:right-3"
/>
<input
v-model="searchQuery"
type="search"
:placeholder="t('COMPANIES.DETAIL.ATTRIBUTES.SEARCH_PLACEHOLDER')"
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
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
class="flex items-center justify-start h-11"
>
<p class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.NO_ATTRIBUTES') }}
</p>
</div>
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
<CompanyCustomAttributeItem
v-for="attribute in filteredUnusedAttributes"
:key="`${company.id}-${attribute.id}`"
:company-id="company.id"
:attribute="attribute"
/>
</div>
</div>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('COMPANIES.DETAIL.ATTRIBUTES.EMPTY_STATE') }}
</p>
</template>
@@ -1,203 +0,0 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import { useCompaniesStore } from 'dashboard/stores/companies';
const props = defineProps({
company: { type: Object, default: () => ({}) },
isLoading: { type: Boolean, default: false },
});
const { t } = useI18n();
const companiesStore = useCompaniesStore();
const form = reactive({ name: '', domain: '', description: '' });
const avatarPreviewUrl = ref('');
const isUploadingAvatar = ref(false);
const uiFlags = computed(() => companiesStore.getUIFlags);
const isUpdating = computed(() => uiFlags.value.updatingItem);
const isAvatarBusy = computed(
() =>
isUploadingAvatar.value || uiFlags.value.deletingAvatar || isUpdating.value
);
const displayName = computed(
() => props.company?.name || t('COMPANIES.UNNAMED')
);
const avatarSource = computed(
() => avatarPreviewUrl.value || props.company?.avatarUrl || ''
);
const isFormInvalid = computed(() => !form.name.trim());
const hasChanges = computed(
() =>
form.name.trim() !== (props.company?.name || '').trim() ||
form.domain.trim() !== (props.company?.domain || '').trim() ||
form.description.trim() !== (props.company?.description || '').trim()
);
const summary = computed(() => {
const { createdAt, lastActivityAt } = props.company || {};
return [
createdAt &&
t('COMPANIES.DETAIL.PROFILE.CREATED_AT', {
date: dynamicTime(createdAt),
}),
lastActivityAt &&
t('COMPANIES.DETAIL.PROFILE.LAST_ACTIVE', {
date: dynamicTime(lastActivityAt),
}),
]
.filter(Boolean)
.join(' • ');
});
const syncForm = company => {
form.name = company?.name || '';
form.domain = company?.domain || '';
form.description = company?.description || '';
};
const isCurrentCompany = companyId => Number(props.company?.id) === companyId;
watch(
() => [
props.company?.id,
props.company?.name,
props.company?.domain,
props.company?.description,
props.company?.avatarUrl,
],
() => {
avatarPreviewUrl.value = '';
syncForm(props.company);
},
{ immediate: true }
);
const handleAvatarUpload = async ({ file, url }) => {
avatarPreviewUrl.value = url;
isUploadingAvatar.value = true;
try {
await companiesStore.update({ id: props.company.id, avatar: file });
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_SUCCESS'));
} catch {
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.UPLOAD_ERROR'));
} finally {
isUploadingAvatar.value = false;
}
};
const handleAvatarDelete = async () => {
try {
await companiesStore.deleteCompanyAvatar(props.company.id);
avatarPreviewUrl.value = '';
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_SUCCESS'));
} catch {
useAlert(t('COMPANIES.DETAIL.AVATAR.DELETE_ERROR'));
}
};
const handleUpdateCompany = async () => {
const companyId = Number(props.company.id);
try {
const updated = await companiesStore.update({
id: companyId,
name: form.name.trim(),
domain: form.domain.trim() || null,
description: form.description.trim() || null,
});
if (!isCurrentCompany(companyId)) return;
syncForm(updated);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_SUCCESS'));
} catch {
if (!isCurrentCompany(companyId)) return;
syncForm(props.company);
useAlert(t('COMPANIES.DETAIL.PROFILE.MESSAGES.UPDATE_ERROR'));
}
};
</script>
<template>
<div v-if="isLoading && !company?.id" class="text-sm text-n-slate-11">
{{ t('COMPANIES.DETAIL.LOADING') }}
</div>
<div v-else-if="company?.id" class="flex flex-col items-start gap-8 pb-6">
<div class="flex flex-col items-start gap-3">
<Avatar
:name="displayName"
:src="avatarSource"
:size="72"
:allow-upload="!isAvatarBusy"
rounded-full
hide-offline-status
@upload="handleAvatarUpload"
@delete="handleAvatarDelete"
/>
<div class="flex flex-col gap-1">
<h3 class="text-base font-medium text-n-slate-12">
{{ displayName }}
</h3>
<span class="text-sm leading-6 text-n-slate-11">{{ summary }}</span>
<p
v-if="isUploadingAvatar || uiFlags.deletingAvatar"
class="text-sm text-n-slate-11"
>
{{ t('COMPANIES.DETAIL.AVATAR.UPDATING') }}
</p>
</div>
</div>
<div class="flex flex-col items-start w-full gap-6">
<span class="py-1 text-sm font-medium text-n-slate-12">
{{ t('COMPANIES.DETAIL.PROFILE.TITLE') }}
</span>
<div class="grid w-full gap-4 sm:grid-cols-2">
<Input
v-model="form.name"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.NAME')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
<Input
v-model="form.domain"
:placeholder="t('COMPANIES.DETAIL.PROFILE.FIELDS.DOMAIN')"
:disabled="isUpdating"
custom-input-class="h-8 !pt-1 !pb-1"
/>
</div>
<TextArea
v-model="form.description"
:placeholder="t('COMPANIES.DETAIL.PROFILE.DESCRIPTION_PLACEHOLDER')"
:disabled="isUpdating"
:max-length="280"
class="w-full"
show-character-count
auto-height
/>
<Button
:label="t('COMPANIES.DETAIL.PROFILE.ACTIONS.SAVE')"
size="sm"
:is-loading="isUpdating"
:disabled="isUpdating || isFormInvalid || !hasChanges"
@click="handleUpdateCompany"
/>
</div>
</div>
</template>
@@ -1,45 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
company: {
type: Object,
default: () => ({}),
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm']);
const { t } = useI18n();
const dialogRef = ref(null);
const description = computed(() =>
props.company?.name
? t('COMPANIES.DETAIL.DELETE.DESCRIPTION_WITH_NAME', {
companyName: props.company.name,
})
: t('COMPANIES.DETAIL.DELETE.DESCRIPTION')
);
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('COMPANIES.DETAIL.DELETE.TITLE')"
:description="description"
:confirm-button-label="t('COMPANIES.DETAIL.DELETE.CONFIRM')"
:is-loading="isLoading"
@confirm="emit('confirm')"
/>
</template>
@@ -35,16 +35,10 @@ const emit = defineEmits([
const lastMessageInChat = computed(() => getLastMessage(props.chat)); const lastMessageInChat = computed(() => getLastMessage(props.chat));
const showLabelsSection = computed(() => props.chat.labels?.length > 0); const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => { const voiceCallData = computed(() => ({
const last = lastMessageInChat.value; status: props.chat.additional_attributes?.call_status,
if (last?.content_type !== 'voice_call' || !last.call) { direction: props.chat.additional_attributes?.call_direction,
return { status: null, direction: null }; }));
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const unreadCount = computed(() => props.chat.unread_count); const unreadCount = computed(() => props.chat.unread_count);
@@ -134,7 +134,7 @@ const handleInputUpdate = async () => {
:message-type="hasError ? 'error' : 'info'" :message-type="hasError ? 'error' : 'info'"
autofocus autofocus
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none" custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate" @keyup.enter="handleInputUpdate"
/> />
<Button <Button
icon="i-lucide-check" icon="i-lucide-check"
@@ -49,11 +49,7 @@ const rules = computed(() => ({
props.attribute.regexPattern && { props.attribute.regexPattern && {
regexValidation: value => { regexValidation: value => {
if (!value) return true; if (!value) return true;
try { return getRegexp(props.attribute.regexPattern).test(value);
return getRegexp(props.attribute.regexPattern).test(value);
} catch {
return false;
}
}, },
}), }),
}, },
@@ -191,7 +187,7 @@ const handleInputUpdate = async () => {
:message="attributeErrorMessage" :message="attributeErrorMessage"
:message-type="hasError ? 'error' : 'info'" :message-type="hasError ? 'error' : 'info'"
custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none" custom-input-class="h-8 ltr:rounded-r-none rtl:rounded-l-none"
@enter="handleInputUpdate" @keyup.enter="handleInputUpdate"
/> />
<Button <Button
icon="i-lucide-check" icon="i-lucide-check"
@@ -201,7 +201,7 @@ onMounted(() => {
v-if="openAgentsList && hasAgentList" v-if="openAgentsList && hasAgentList"
:menu-items="agentList" :menu-items="agentList"
show-search show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-60" class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-60"
@action="handleArticleAction" @action="handleArticleAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -233,7 +233,7 @@ onMounted(() => {
v-if="openCategoryList && hasCategoryMenuItems" v-if="openCategoryList && hasCategoryMenuItems"
:menu-items="categoryList" :menu-items="categoryList"
show-search show-search
class="w-48 mt-2 z-[100] left-0 top-full max-h-60" class="w-48 mt-2 z-[100] overflow-y-auto left-0 top-full max-h-60"
@action="handleArticleAction" @action="handleArticleAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -159,7 +159,7 @@ const handleTabChange = value => {
v-if="isLocaleMenuOpen" v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems" :menu-items="localeMenuItems"
show-search show-search
class="left-0 w-40 max-w-[300px] mt-2 xl:right-0 top-full max-h-60" class="left-0 w-40 max-w-[300px] mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction" @action="handleLocaleAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -180,7 +180,7 @@ const handleTabChange = value => {
v-if="isCategoryMenuOpen" v-if="isCategoryMenuOpen"
:menu-items="categoryMenuItems" :menu-items="categoryMenuItems"
show-search show-search
class="left-0 w-48 mt-2 xl:right-0 top-full max-h-60" class="left-0 w-48 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleCategoryAction" @action="handleCategoryAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -296,7 +296,7 @@ watch(
:selected-count-label="selectedCountLabel" :selected-count-label="selectedCountLabel"
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between" class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
> >
<template #secondaryActions> <template #secondary-actions>
<Button <Button
sm sm
ghost ghost
@@ -142,7 +142,7 @@ const handleBreadcrumbClick = () => {
v-if="isLocaleMenuOpen" v-if="isLocaleMenuOpen"
:menu-items="localeMenuItems" :menu-items="localeMenuItems"
show-search show-search
class="left-0 w-40 mt-2 xl:right-0 top-full max-h-60" class="left-0 w-40 mt-2 overflow-y-auto xl:right-0 top-full max-h-60"
@action="handleLocaleAction" @action="handleLocaleAction"
/> />
</OnClickOutside> </OnClickOutside>
@@ -90,7 +90,7 @@ const targetInboxLabel = computed(() => {
<DropdownMenu <DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown" v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList" :menu-items="contactableInboxesList"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-fit max-w-sm dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)" @action="emit('handleInboxAction', $event)"
/> />
</div> </div>
@@ -1,3 +1,5 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
@@ -18,13 +20,11 @@ const emit = defineEmits(['action']);
const bannerClass = computed(() => { const bannerClass = computed(() => {
const classMap = { const classMap = {
slate: slate: 'bg-n-slate-3 border-n-slate-4 text-n-slate-11',
'bg-n-slate-3 border-n-slate-4 text-n-slate-11 [&_.link]:text-n-slate-11', amber: 'bg-n-amber-3 border-n-amber-4 text-n-amber-11',
amber: teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11',
'bg-n-amber-3 border-n-amber-4 text-n-amber-11 [&_.link]:text-n-amber-11', ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11',
teal: 'bg-n-teal-3 border-n-teal-4 text-n-teal-11 [&_.link]:text-n-teal-11', blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11',
ruby: 'bg-n-ruby-3 border-n-ruby-4 text-n-ruby-11 [&_.link]:text-n-ruby-11',
blue: 'bg-n-blue-3 border-n-blue-4 text-n-blue-11 [&_.link]:text-n-blue-11',
}; };
return classMap[props.color]; return classMap[props.color];
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, useSlots } from 'vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
@@ -47,6 +47,9 @@ const allSelected = computed(
selectedVisibleCount.value === visibleItemCount.value selectedVisibleCount.value === visibleItemCount.value
); );
const slots = useSlots();
const hasSecondaryActions = computed(() => Boolean(slots['secondary-actions']));
const bulkCheckboxState = computed({ const bulkCheckboxState = computed({
get: () => allSelected.value, get: () => allSelected.value,
set: shouldSelectAll => { set: shouldSelectAll => {
@@ -92,12 +95,10 @@ const bulkCheckboxState = computed({
<span class="text-sm text-n-slate-10 truncate tabular-nums"> <span class="text-sm text-n-slate-10 truncate tabular-nums">
{{ selectedCountLabel }} {{ selectedCountLabel }}
</span> </span>
<div v-if="$slots.primaryActions" class="h-4 w-px bg-n-strong" />
<slot v-if="$slots.primaryActions" name="primaryActions" />
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot v-if="$slots.secondaryActions" name="secondaryActions" /> <slot v-if="hasSecondaryActions" name="secondary-actions" />
<div v-if="$slots.secondaryActions" class="h-4 w-px bg-n-strong" /> <div v-if="hasSecondaryActions" class="h-4 w-px bg-n-strong" />
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<slot name="actions" :selected-count="selectedCount"> <slot name="actions" :selected-count="selectedCount">
<Button <Button
@@ -10,6 +10,9 @@ const props = defineProps({
menuItems: { menuItems: {
type: Array, type: Array,
default: () => [], default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
}, },
menuSections: { menuSections: {
type: Array, type: Array,
@@ -19,10 +22,6 @@ const props = defineProps({
type: Number, type: Number,
default: 20, default: 20,
}, },
roundedThumbnail: {
type: Boolean,
default: true,
},
showSearch: { showSearch: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -43,17 +42,9 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isLoading: {
type: Boolean,
default: false,
},
emptyStateMessage: {
type: String,
default: 'DROPDOWN_MENU.EMPTY_STATE',
},
}); });
const emit = defineEmits(['action', 'search', 'empty']); const emit = defineEmits(['action', 'search']);
const { t } = useI18n(); const { t } = useI18n();
@@ -105,13 +96,9 @@ const filteredMenuSections = computed(() => {
}); });
const handleSearchInput = event => { const handleSearchInput = event => {
emit('search', event.target.value); if (props.disableLocalFiltering) {
emit('search', event.target.value);
const isEmpty = hasSections.value }
? filteredMenuSections.value.length === 0
: filteredMenuItems.value.length === 0;
if (isEmpty) emit('empty');
}; };
const handleAction = item => { const handleAction = item => {
@@ -136,104 +123,57 @@ onMounted(() => {
<template> <template>
<div <div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 flex flex-col min-w-[136px] shadow-lg pt-2 overflow-hidden" class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
> >
<div v-if="showSearch" class="relative shrink-0 px-2 mb-2"> <div
<span v-if="showSearch"
class="absolute i-lucide-search size-3.5 top-2 ltr:left-5 rtl:right-5" class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2 z-20"
/> >
<input <div class="relative">
ref="searchInput" <span class="absolute i-lucide-search size-3.5 top-2 left-3" />
v-model="searchQuery" <input
type="search" ref="searchInput"
:placeholder=" v-model="searchQuery"
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER') type="search"
" :placeholder="
class="reset-base w-full h-8 py-2 ltr:pl-10 ltr:pr-2 rtl:pl-2 rtl:pr-10 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
@input="handleSearchInput" "
/> class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
@input="handleSearchInput"
/>
</div>
</div> </div>
<div class="flex flex-col gap-2 overflow-y-auto min-h-0 px-2 pb-2"> <template v-if="hasSections">
<template v-if="hasSections"> <div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky z-10 bg-n-alpha-3 backdrop-blur-sm"
:class="showSearch ? 'top-10' : 'top-0'"
>
{{ section.title }}
</p>
<div <div
v-for="(section, sectionIndex) in filteredMenuSections" v-if="section.isLoading"
:key="section.title || sectionIndex" class="flex items-center justify-center py-2"
class="flex flex-col gap-1"
> >
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
>
{{ section.title }}
</p>
<div
v-if="section.isLoading"
class="flex items-center justify-center py-2"
>
<Spinner :size="24" />
</div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
:rounded-full="roundedThumbnail"
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
</slot>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
class="min-w-0 text-sm font-420 truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<div v-if="isLoading" class="flex items-center justify-center py-2">
<Spinner :size="24" /> <Spinner :size="24" />
</div> </div>
<div
v-else-if="!section.items.length && section.emptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ section.emptyState }}
</div>
<button <button
v-for="(item, index) in filteredMenuItems" v-for="(item, itemIndex) in section.items"
:key="index" :key="item.value || itemIndex"
type="button" type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50" class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{ :class="{
@@ -250,44 +190,77 @@ onMounted(() => {
:name="item.thumbnail.name" :name="item.thumbnail.name"
:src="item.thumbnail.src" :src="item.thumbnail.src"
:size="thumbnailSize" :size="thumbnailSize"
:rounded-full="roundedThumbnail" rounded-full
/>
</slot>
<slot name="icon" :item="item">
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/> />
</slot> </slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span> <span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item"> <span
<span v-if="item.label"
v-if="item.label" class="min-w-0 text-sm truncate"
class="min-w-0 text-sm font-420 truncate" :class="labelClass"
:class="labelClass" >
> {{ item.label }}
{{ item.label }} </span>
</span>
</slot>
<slot name="trailing-icon" :item="item" />
</button> </button>
</template> <div
<div v-if="sectionIndex < filteredMenuSections.length - 1"
v-if="shouldShowEmptyState" class="h-px bg-n-alpha-2 mx-2 my-1"
class="text-sm text-n-slate-11 px-2 py-1.5" />
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: searchQuery
? t('DROPDOWN_MENU.EMPTY_STATE')
: t(emptyStateMessage)
}}
</div> </div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</div> </div>
<div v-if="$slots.footer" class="shrink-0"> <slot name="footer" />
<slot name="footer" />
</div>
</div> </div>
</template> </template>
@@ -35,7 +35,7 @@ const showDropdown = ref(false);
v-on-clickaway="() => (showDropdown = false)" v-on-clickaway="() => (showDropdown = false)"
:menu-items="labelMenuItems" :menu-items="labelMenuItems"
show-search show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52" class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="emit('updateLabel', $event)" @action="emit('updateLabel', $event)"
> >
<template #thumbnail="{ item }"> <template #thumbnail="{ item }">
@@ -113,7 +113,6 @@ const props = defineProps({
validator: value => Object.values(MESSAGE_STATUS).includes(value), validator: value => Object.values(MESSAGE_STATUS).includes(value),
}, },
attachments: { type: Array, default: () => [] }, attachments: { type: Array, default: () => [] },
call: { type: Object, default: null }, // eslint-disable-line vue/no-unused-properties
content: { type: String, default: null }, content: { type: String, default: null },
contentAttributes: { type: Object, default: () => ({}) }, contentAttributes: { type: Object, default: () => ({}) },
contentType: { contentType: {
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
<div class="p-3 bg-n-alpha-2 rounded-xl">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="flex gap-2">
<Button label="Call us" slate class="!text-n-blue-11 w-full" />
<Button label="Visit our website" slate class="!text-n-blue-11 w-full" />
</div>
</div>
</template>
@@ -10,22 +10,22 @@ defineProps({
<template> <template>
<div <div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80" class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
> >
<div class="px-3 py-2.5"> <div class="px-3 py-2.5">
<img :src="message.image_url" class="w-full max-h-44 rounded-lg" /> <img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<div class="flex flex-col gap-2 pt-2.5"> <div class="pt-2.5 flex flex-col gap-2">
<h6 class="font-semibold">{{ message.title }}</h6> <h6 class="font-semibold">{{ message.title }}</h6>
<span <span
v-dompurify-html="message.content" v-dompurify-html="message.content"
class="text-sm prose prose-bubble" class="prose prose-bubble text-sm"
/> />
</div> </div>
</div> </div>
<div class="flex justify-center items-center p-3"> <div class="p-3 flex items-center justify-center">
<Button label="Call us to order" link class="hover:!no-underline" /> <Button label="Call us to order" link class="hover:!no-underline" />
</div> </div>
<div class="flex justify-center items-center p-3"> <div class="p-3 flex items-center justify-center">
<Button label="Visit our store" link class="hover:!no-underline" /> <Button label="Visit our store" link class="hover:!no-underline" />
</div> </div>
</div> </div>
@@ -0,0 +1,25 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="See options" link class="hover:!no-underline" />
</div>
</div>
</template>
@@ -0,0 +1,20 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
>
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
</template>
@@ -0,0 +1,68 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="prose prose-bubble font-medium text-sm"
/>
</div>
<div class="p-3 flex items-center justify-center">
<Button label="No, that will be all" link class="hover:!no-underline">
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
<div class="p-3 flex items-center justify-center">
<Button
label="I want to talk to an agents"
link
class="hover:!no-underline"
>
<template #icon>
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
class="stroke-n-blue-text"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
stroke-width="1.333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
</Button>
</div>
</div>
</template>
@@ -8,7 +8,7 @@ defineProps({
</script> </script>
<template> <template>
<div class="p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"> <div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
<span v-dompurify-html="message.content" class="prose prose-bubble" /> <span v-dompurify-html="message.content" class="prose prose-bubble" />
</div> </div>
</template> </template>
@@ -1,21 +1,21 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useMessageContext } from '../provider.js'; import { useMessageContext } from '../provider.js';
import { VOICE_CALL_STATUS } from '../constants'; import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
import { useCallSession } from 'dashboard/composables/useCallSession';
import { formatDuration } from 'shared/helpers/timeHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue';
import BaseBubble from 'next/message/bubbles/Base.vue'; import BaseBubble from 'next/message/bubbles/Base.vue';
import AudioChip from 'next/message/chips/Audio.vue';
const LABEL_MAP = { const LABEL_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS', [VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED', [VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
}; };
const SUBTEXT_MAP = {
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
};
const ICON_MAP = { const ICON_MAP = {
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call', [VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x', [VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
@@ -30,41 +30,15 @@ const BG_COLOR_MAP = {
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9', [VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
}; };
const { t } = useI18n(); const { contentAttributes, messageType } = useMessageContext();
const store = useStore();
const { call, conversationId, currentUserId, inboxId } = useMessageContext();
const { joinCall, endCall, activeCall, hasActiveCall, isJoining } =
useCallSession();
const status = computed(() => call.value?.status); const data = computed(() => contentAttributes.value?.data);
const isOutbound = computed(() => call.value?.direction === 'outgoing'); const status = computed(() => data.value?.status?.toString());
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
const isFailed = computed(() => const isFailed = computed(() =>
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
); );
const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId);
const didCurrentUserAnswer = computed(
() =>
!!acceptedByAgentId.value && acceptedByAgentId.value === currentUserId.value
);
// Pickup auto-assigns the conversation, so the assignee is a safe display proxy
// for the answerer when the Call payload lacks accepted_by_agent_id (e.g.,
// Twilio's call-status webhook flipped the call to in-progress before the
// participant-join webhook claimed it).
const conversationAssignee = computed(() => {
const conversation = store.getters.getConversationById?.(
conversationId?.value
);
return conversation?.meta?.assignee || null;
});
const displayAgentName = computed(() => {
if (call.value?.acceptedByAgentName) return call.value.acceptedByAgentName;
if (acceptedByAgentId.value) {
const agent = store.getters['agents/getAgentById'](acceptedByAgentId.value);
if (agent?.available_name) return agent.available_name;
if (agent?.name) return agent.name;
}
return conversationAssignee.value?.name || null;
});
const labelKey = computed(() => { const labelKey = computed(() => {
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value]; if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
@@ -78,32 +52,16 @@ const labelKey = computed(() => {
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL'; : 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
}); });
const formattedDuration = computed(() => const subtextKey = computed(() => {
formatDuration(call.value?.durationSeconds) if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
);
const subtext = computed(() => {
if (status.value === VOICE_CALL_STATUS.RINGING) {
return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET');
}
if (status.value === VOICE_CALL_STATUS.COMPLETED) {
return formattedDuration.value;
}
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) { if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
if (isOutbound.value) return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED'); return isOutbound.value
if (didCurrentUserAnswer.value) { ? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
return t('CONVERSATION.VOICE_CALL.YOU_ANSWERED'); : 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
}
if (displayAgentName.value) {
return t('CONVERSATION.VOICE_CALL.AGENT_ANSWERED', {
agentName: displayAgentName.value,
});
}
return t('CONVERSATION.VOICE_CALL.THEY_ANSWERED');
} }
return isFailed.value return isFailed.value
? t('CONVERSATION.VOICE_CALL.NO_ANSWER') ? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
: t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); : 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
}); });
const iconName = computed(() => { const iconName = computed(() => {
@@ -112,61 +70,11 @@ const iconName = computed(() => {
}); });
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9'); const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
const callSid = computed(() => call.value?.providerCallId);
// Show "Join call" when the call is still ringing, no agent has claimed it,
// and the conversation is unassigned or assigned to the current user. Mirrors
// the eligibility used by FloatingCallWidget so the bubble can act as a
// recovery affordance after a refresh or missed widget.
const canJoinCall = computed(() => {
if (status.value !== VOICE_CALL_STATUS.RINGING) return false;
if (isOutbound.value) return false;
if (acceptedByAgentId.value) return false;
if (!callSid.value || !inboxId.value || !conversationId.value) return false;
// Suppress the button once this call is the local active session — the
// message status webhook may lag behind, so we can't rely on `status` alone
// to hide it after a successful join from this client.
if (hasActiveCall.value && activeCall.value?.callSid === callSid.value)
return false;
const assignee = conversationAssignee.value;
if (assignee?.id && assignee.id !== currentUserId.value) return false;
return true;
});
const recordingAttachment = computed(() => {
const url = call.value?.recordingUrl;
if (!url) return null;
return {
dataUrl: url,
fileType: 'audio',
extension: 'wav',
transcribedText: call.value?.transcript || '',
};
});
const handleJoinCall = async () => {
if (!canJoinCall.value || isJoining.value) return;
if (hasActiveCall.value && activeCall.value?.callSid !== callSid.value) {
await endCall({
conversationId: activeCall.value.conversationId,
inboxId: activeCall.value.inboxId,
callSid: activeCall.value.callSid,
});
}
await joinCall({
conversationId: conversationId.value,
inboxId: inboxId.value,
callSid: callSid.value,
});
};
</script> </script>
<template> <template>
<BaseBubble class="p-0 border-none" hide-meta> <BaseBubble class="p-0 border-none" hide-meta>
<div class="flex overflow-hidden flex-col w-full max-w-sm"> <div class="flex overflow-hidden flex-col w-full max-w-xs">
<div class="flex gap-3 items-center p-3 w-full"> <div class="flex gap-3 items-center p-3 w-full">
<div <div
class="flex justify-center items-center rounded-full size-10 shrink-0" class="flex justify-center items-center rounded-full size-10 shrink-0"
@@ -186,24 +94,11 @@ const handleJoinCall = async () => {
<span class="text-sm font-medium truncate text-n-slate-12"> <span class="text-sm font-medium truncate text-n-slate-12">
{{ $t(labelKey) }} {{ $t(labelKey) }}
</span> </span>
<span v-if="subtext" class="text-xs text-n-slate-11"> <span class="text-xs text-n-slate-11">
{{ subtext }} {{ $t(subtextKey) }}
</span> </span>
<button
v-if="canJoinCall"
type="button"
class="p-0 mt-1 text-xs font-medium text-start text-n-teal-10 hover:text-n-teal-11 disabled:opacity-50"
:disabled="isJoining"
@click="handleJoinCall"
>
{{ $t('CONVERSATION.VOICE_CALL.JOIN_CALL') }}
</button>
</div> </div>
</div> </div>
<div v-if="recordingAttachment" class="px-3 pb-3">
<AudioChip :attachment="recordingAttachment" />
</div>
</div> </div>
</BaseBubble> </BaseBubble>
</template> </template>
@@ -0,0 +1,21 @@
<script setup>
import CallToAction from '../../bubbles/Template/CallToAction.vue';
const message = {
content:
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/CallToAction"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Call To Action">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<CallToAction :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Card from '../../bubbles/Template/Card.vue';
const message = {
title: 'Two in one cake (1 pound)',
content: 'Customize your order for special occasions',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Card"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Card">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Card :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import ListPicker from '../../bubbles/Template/ListPicker.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you? `,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/ListPicker"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="ListPicker">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<ListPicker :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,23 @@
<script setup>
import Media from '../../bubbles/Template/Media.vue';
const message = {
content:
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
image_url:
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Media"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Image Media">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Media :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,21 @@
<script setup>
import QuickReply from '../../bubbles/Template/QuickReply.vue';
const message = {
content: `Hey there! Thanks for reaching out to us. Could you let us know
what you need to help us better assist you?`,
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/QuickReply"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Quick Replies">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<QuickReply :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,20 @@
<script setup>
import Text from '../../bubbles/Template/Text.vue';
const message = {
content: 'Hello John, how may we assist you?',
};
</script>
<template>
<Story
title="Components/Message Bubbles/Template/Text"
:layout="{ type: 'grid', width: '600px' }"
>
<Variant title="Default Text">
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
<Text :message="message" />
</div>
</Variant>
</Story>
</template>
@@ -209,7 +209,7 @@ watch(
v-if="showDropdown" v-if="showDropdown"
:menu-items="filteredCountries" :menu-items="filteredCountries"
show-search show-search
class="z-[100] w-48 mt-2 ltr:left-0 rtl:right-0 top-full max-h-52" class="z-[100] w-48 mt-2 overflow-y-auto ltr:left-0 rtl:right-0 top-full max-h-52"
@action="onSelectCountry" @action="onSelectCountry"
/> />
</div> </div>
@@ -457,7 +457,7 @@ const menuItems = computed(() => {
{}, {},
{ page: 1, search: undefined } { page: 1, search: undefined }
), ),
activeOn: ['companies_dashboard_index', 'companies_dashboard_show'], activeOn: ['companies_dashboard_index'],
}, },
], ],
}, },
@@ -247,7 +247,7 @@ const handleBlur = e => emit('blur', e);
v-if="showDropdownMenu" v-if="showDropdownMenu"
:menu-items="filteredMenuItems" :menu-items="filteredMenuItems"
:is-searching="isLoading" :is-searching="isLoading"
class="ltr:left-0 rtl:right-0 z-[100] top-8 max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5" class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction" @action="handleDropdownAction"
/> />
</div> </div>
@@ -1,33 +0,0 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
<div class="p-3 rounded-xl bg-n-alpha-2">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-if="message.buttons && message.buttons.length > 0"
class="flex flex-col gap-2"
>
<Button
v-for="(button, index) in message.buttons"
:key="index"
:label="button.text || button.title || 'Button'"
slate
class="!text-n-blue-11 w-full"
/>
</div>
</div>
</template>
@@ -1,67 +0,0 @@
<script setup>
import { computed } from 'vue';
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const PDF_EXTENSIONS = ['.pdf', 'pdf'];
const VIDEO_EXTENSIONS = ['.mp4', '.mov', 'video'];
const DOC_EXTENSIONS = ['.doc'];
const mediaType = computed(() => {
if (props.message.mediaType) return props.message.mediaType;
const format = props.message.header?.format;
if (format) return format.toLowerCase();
const url = props.message.image_url || '';
if (PDF_EXTENSIONS.some(ext => url.includes(ext))) return 'document';
if (VIDEO_EXTENSIONS.some(ext => url.includes(ext))) return 'video';
return 'image';
});
const fileType = computed(() => {
const url = props.message.image_url || '';
return DOC_EXTENSIONS.some(ext => url.includes(ext)) ? 'doc' : 'pdf';
});
</script>
<template>
<div
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<img
v-if="mediaType === 'image'"
:src="message.image_url"
class="object-cover w-full max-h-44 rounded-lg"
alt="Template media"
/>
<div
v-else-if="mediaType === 'video'"
class="overflow-hidden relative rounded-lg"
>
<video
:src="message.image_url"
class="object-cover w-full max-h-44"
controls
preload="metadata"
/>
</div>
<div v-else-if="mediaType === 'document'" class="flex items-center">
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
</div>
<span
v-if="message.content"
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
</template>
@@ -1,42 +0,0 @@
<script setup>
import { computed } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
message: {
type: Object,
required: true,
},
});
const actions = computed(() => props.message.actions || []);
</script>
<template>
<div
class="rounded-xl divide-y bg-n-alpha-2 divide-n-strong text-n-slate-12 max-w-80"
>
<div class="p-3">
<span
v-dompurify-html="message.content"
class="text-sm font-medium prose prose-bubble"
/>
</div>
<div
v-for="(action, index) in actions"
:key="index"
class="flex justify-center items-center p-3"
>
<Button
:label="action.title || action.text || 'Button'"
link
class="hover:!no-underline"
>
<template #icon>
<Icon icon="i-woot-quick-reply" class="size-[15px]" />
</template>
</Button>
</div>
</div>
</template>
@@ -1,105 +0,0 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
const findWhatsApp = name => whatsAppTemplates.find(t => t.name === name);
const findTwilio = name => twilioTemplates.find(t => t.friendly_name === name);
const greet = findWhatsApp('greet');
const eventInvitation = findWhatsApp('event_invitation_static');
const orderConfirmation = findWhatsApp('order_confirmation');
const discountCoupon = findWhatsApp('discount_coupon');
const trainingVideo = findWhatsApp('training_video');
const twilioGreet = findTwilio('greet');
const shoeLaunch = findTwilio('shoe_launch');
const welcomeMessage = findTwilio('welcome_message_new');
const courseFeeReminder = findTwilio('course_fee_reminder');
</script>
<template>
<Story
title="Components/TemplatePreview"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WhatsApp - Simple Text">
<TemplatePreview
:template="greet"
:variables="getWhatsAppVariables(greet)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Simple Text (No Variables)">
<TemplatePreview :template="greet" :variables="{}" platform="whatsapp" />
</Variant>
<Variant title="WhatsApp - Call to Action Buttons">
<TemplatePreview
:template="eventInvitation"
:variables="getWhatsAppVariables(eventInvitation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Image Media">
<TemplatePreview
:template="orderConfirmation"
:variables="getWhatsAppVariables(orderConfirmation)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Video Media">
<TemplatePreview
:template="trainingVideo"
:variables="getWhatsAppVariables(trainingVideo)"
platform="whatsapp"
/>
</Variant>
<Variant title="WhatsApp - Copy Code">
<TemplatePreview
:template="discountCoupon"
:variables="getWhatsAppVariables(discountCoupon)"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio - Text">
<TemplatePreview
:template="twilioGreet"
:variables="twilioGreet.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Media">
<TemplatePreview
:template="shoeLaunch"
:variables="shoeLaunch.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Quick Reply">
<TemplatePreview
:template="welcomeMessage"
:variables="welcomeMessage.variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio - Call to Action">
<TemplatePreview
:template="courseFeeReminder"
:variables="courseFeeReminder.variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -1,112 +0,0 @@
<script setup>
import { computed } from 'vue';
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
import {
PLATFORMS,
TEMPLATE_TYPES,
WA_HEADER_FORMATS,
WA_MEDIA_FORMATS,
} from 'dashboard/services/TemplateConstants';
import CardTemplate from './CardTemplate.vue';
import CallToActionTemplate from './CallToActionTemplate.vue';
import MediaTemplate from './MediaTemplate.vue';
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
import QuickReplyTemplate from './QuickReplyTemplate.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
variables: {
type: Object,
default: () => ({}),
},
platform: {
type: String,
required: true,
validator: value => Object.values(PLATFORMS).includes(value),
},
});
const COMPONENT_MAP = {
[TEMPLATE_TYPES.WHATSAPP_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: MediaTemplate,
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: CallToActionTemplate,
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_TEXT]: WhatsAppTextTemplate,
[TEMPLATE_TYPES.TWILIO_MEDIA]: MediaTemplate,
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: QuickReplyTemplate,
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: CallToActionTemplate,
[TEMPLATE_TYPES.TWILIO_CARD]: CardTemplate,
};
const substituteVariables = (text, variables) => {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
const value = variables[variable];
return value !== undefined && value !== '' ? value : `[${variable}]`;
});
};
const processedTemplate = computed(() => {
const normalized = TemplateNormalizer.normalize(
props.template,
props.platform
);
let content = '';
let imageUrl = '';
let title = '';
let footer = '';
if (props.platform === PLATFORMS.WHATSAPP) {
content = normalized.body?.text || '';
if (normalized.header) {
if (WA_MEDIA_FORMATS.includes(normalized.header.format)) {
imageUrl = normalized.header.example?.header_handle?.[0] || '';
}
if (normalized.header.format === WA_HEADER_FORMATS.TEXT) {
title = normalized.header.text || '';
}
}
footer = normalized.footer?.text || '';
} else {
content = normalized.body || '';
if (normalized.media && normalized.media.length > 0) {
imageUrl = normalized.media[0];
}
}
const buttons = normalized.buttons?.length
? normalized.buttons
: normalized.actions || [];
return {
...normalized,
content: substituteVariables(content, props.variables),
title: substituteVariables(title, props.variables),
footer: substituteVariables(footer, props.variables),
image_url: substituteVariables(imageUrl, props.variables),
buttons,
actions: normalized.actions || [],
};
});
const previewComponent = computed(
() => COMPONENT_MAP[processedTemplate.value.type] || WhatsAppTextTemplate
);
</script>
<template>
<div class="template-preview">
<component :is="previewComponent" :message="processedTemplate" />
</div>
</template>
@@ -1,199 +0,0 @@
<script setup>
import TemplatePreview from './TemplatePreview.vue';
import {
whatsAppTemplates,
getWhatsAppVariables,
} from './templates/whatsapp-templates.js';
import { twilioTemplates } from './templates/twilio-templates.js';
</script>
<template>
<Story
title="Components/TemplatePreviewExamples"
:layout="{ type: 'grid', width: 400 }"
>
<Variant title="WA: Event Invitation (Buttons)">
<TemplatePreview
:template="whatsAppTemplates[0]"
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Purchase Receipt (Document)">
<TemplatePreview
:template="whatsAppTemplates[1]"
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Discount Coupon (Copy Code)">
<TemplatePreview
:template="whatsAppTemplates[2]"
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Support Callback (Phone)">
<TemplatePreview
:template="whatsAppTemplates[3]"
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Training Video (Video)">
<TemplatePreview
:template="whatsAppTemplates[4]"
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Order Confirmation (Image)">
<TemplatePreview
:template="whatsAppTemplates[5]"
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Product Launch (Image + Footer)">
<TemplatePreview
:template="whatsAppTemplates[6]"
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Technician Visit (Header + Buttons)">
<TemplatePreview
:template="whatsAppTemplates[7]"
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Greet (Simple Text)">
<TemplatePreview
:template="whatsAppTemplates[8]"
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Hello World (Header + Footer)">
<TemplatePreview
:template="whatsAppTemplates[9]"
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Feedback Request (Button)">
<TemplatePreview
:template="whatsAppTemplates[10]"
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Address Update (Header)">
<TemplatePreview
:template="whatsAppTemplates[11]"
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
platform="whatsapp"
/>
</Variant>
<Variant title="WA: Delivery Confirmation">
<TemplatePreview
:template="whatsAppTemplates[12]"
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
platform="whatsapp"
/>
</Variant>
<Variant title="Twilio: Shoe Launch (Media)">
<TemplatePreview
:template="twilioTemplates[0]"
:variables="twilioTemplates[0].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch Custom Price (Media)">
<TemplatePreview
:template="twilioTemplates[1]"
:variables="twilioTemplates[1].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Product Launch (Media)">
<TemplatePreview
:template="twilioTemplates[2]"
:variables="twilioTemplates[2].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Greet (Text)">
<TemplatePreview
:template="twilioTemplates[3]"
:variables="twilioTemplates[3].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Order Status (Text)">
<TemplatePreview
:template="twilioTemplates[4]"
:variables="twilioTemplates[4].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Hello World (Text)">
<TemplatePreview
:template="twilioTemplates[5]"
:variables="twilioTemplates[5].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message New (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[6]"
:variables="twilioTemplates[6].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[7]"
:variables="twilioTemplates[7].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Welcome Message (Quick Reply)">
<TemplatePreview
:template="twilioTemplates[8]"
:variables="twilioTemplates[8].variables"
platform="twilio"
/>
</Variant>
<Variant title="Twilio: Course Fee Reminder (Call to Action)">
<TemplatePreview
:template="twilioTemplates[9]"
:variables="twilioTemplates[9].variables"
platform="twilio"
/>
</Variant>
</Story>
</template>
@@ -1,26 +0,0 @@
<script setup>
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div
class="flex flex-col gap-2 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
>
<div v-if="message.title" class="text-base font-bold">
{{ message.title }}
</div>
<div v-if="message.content" class="text-sm font-medium prose prose-bubble">
<span v-dompurify-html="message.content" />
</div>
<div v-if="message.footer" class="text-xs opacity-70 text-n-slate-11">
{{ message.footer }}
</div>
</div>
</template>
@@ -1,6 +0,0 @@
// Main Template Preview Component
export { default as TemplatePreview } from './TemplatePreview.vue';
// Core Services
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
@@ -1,209 +0,0 @@
export const twilioTemplates = [
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '100$',
},
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
friendly_name: 'shoe_launch',
template_type: 'media',
},
{
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
types: {
'twilio/media': {
body: 'Introducing our latest release  the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'Jordan',
2: '400$',
},
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
friendly_name: 'product_launch_custom_price',
template_type: 'media',
},
{
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
types: {
'twilio/media': {
body: 'Introducing our latest release  the Nike Air Force! Available now for just $129.99',
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
friendly_name: 'product_launch',
template_type: 'media',
},
{
body: 'Hey {{1}}, how may I help you?',
types: {
'twilio/text': {
body: 'Hey {{1}}, how may I help you?',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
},
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
friendly_name: 'greet',
template_type: 'text',
},
{
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
types: {
'twilio/text': {
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
1: 'John',
3: '12345',
},
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
friendly_name: 'order_status',
template_type: 'text',
},
{
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
types: {
'twilio/text': {
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {},
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
friendly_name: 'hello_world',
template_type: 'text',
},
{
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
actions: [
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Support_payload',
title: 'Support',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
friendly_name: 'welcome_message_new',
template_type: 'quick_reply',
},
{
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
types: {
'twilio/quick-reply': {
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
actions: [
{
id: 'Chatwoot Cloud_payload',
title: 'Chatwoot Cloud',
},
{
id: 'Self-hosted Chatwoot_payload',
title: 'Self-hosted Chatwoot',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
friendly_name: 'saas_whatsapp_question',
template_type: 'quick_reply',
},
{
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
types: {
'twilio/quick-reply': {
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
actions: [
{
id: 'Support_payload',
title: 'Support',
},
{
id: 'Sales_payload',
title: 'Sales',
},
{
id: 'Demo_payload',
title: 'Demo',
},
],
},
},
status: 'approved',
category: 'utility',
language: 'en_US',
variables: {},
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
friendly_name: 'welcome_message',
template_type: 'quick_reply',
},
{
types: {
'twilio/call-to-action': {
actions: [
{
id: null,
title: 'Pay now',
type: 'URL',
url: 'https://payments.example.com/pay',
},
],
body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
},
},
status: 'approved',
category: 'utility',
language: 'en',
variables: {
date: '01-Jan-2026',
},
content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
friendly_name: 'course_fee_reminder',
template_type: 'call_to_action',
},
];
@@ -1,408 +0,0 @@
export const whatsAppTemplates = [
{
id: '1381151706284063',
name: 'event_invitation_static',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'F1',
param_name: 'event_name',
},
{
example: 'Dubai',
param_name: 'location',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '767076159336759',
name: 'purchase_receipt',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
example: {
header_handle: [
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
],
},
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
example: {
body_text: [['credit', 'CS Mutual', 'receipt']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1469258364071127',
name: 'discount_coupon',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
example: {
body_text_named_params: [
{
example: '30',
param_name: 'discount_percentage',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
example: ['SAVE1OFF'],
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1075221534579807',
name: 'support_callback',
status: 'APPROVED',
category: 'UTILITY',
language: 'en',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
{
example: '232323',
param_name: 'ticket_id',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+2112121212',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1023596726651144',
name: 'training_video',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'VIDEO',
example: {
header_handle: [
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
],
},
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'john',
param_name: 'name',
},
{
example: 'July 31',
param_name: 'date',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1106685194739985',
name: 'order_confirmation',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
example: {
body_text: [['blue canvas shoes']],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'POSITIONAL',
},
{
id: '1242180011253003',
name: 'product_launch',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
},
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'blue',
param_name: 'color',
},
],
},
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1449876326175680',
name: 'technician_visit',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
example: {
body_text: [
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
],
},
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
parameter_format: 'POSITIONAL',
},
{
id: '997298832221901',
name: 'greet',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'John',
param_name: 'customer_name',
},
],
},
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '632315222954611',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameter_format: 'POSITIONAL',
},
{
id: '787864066907971',
name: 'feedback_request',
status: 'APPROVED',
category: 'MARKETING',
language: 'en',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
example: {
body_text_named_params: [
{
example: 'muhsin',
param_name: 'name',
},
],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
sub_category: 'CUSTOM',
parameter_format: 'NAMED',
},
{
id: '1938057163677205',
name: 'address_update',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
example: {
body_text: [['John', '123 Main St', 'support@telco.com']],
},
},
],
parameter_format: 'POSITIONAL',
},
{
id: '1644094842949394',
name: 'delivery_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
type: 'BODY',
example: {
body_text: [['John', 'Jan 1, 2024']],
},
},
],
parameter_format: 'POSITIONAL',
},
];
// Helper function to get variable values from examples
export const getWhatsAppVariables = template => {
const variables = {};
template.components?.forEach(component => {
if (component.example?.body_text_named_params) {
component.example.body_text_named_params.forEach(param => {
variables[param.param_name] = param.example;
});
}
if (component.example?.body_text) {
component.example.body_text[0]?.forEach((value, index) => {
variables[(index + 1).toString()] = value;
});
}
});
return variables;
};
@@ -1,7 +1,5 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import Label from 'dashboard/components-next/label/Label.vue';
defineProps({ defineProps({
title: { title: {
@@ -20,13 +18,7 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isBeta: {
type: Boolean,
default: false,
},
}); });
const { t } = useI18n();
</script> </script>
<template> <template>
@@ -45,18 +37,9 @@ const { t } = useI18n();
</div> </div>
<div class="flex flex-col items-start gap-1.5"> <div class="flex flex-col items-start gap-1.5">
<div class="flex items-center gap-2"> <h3 class="text-n-slate-12 text-sm text-start font-medium capitalize">
<h3 class="text-n-slate-12 text-sm text-start font-medium capitalize"> {{ title }}
{{ title }} </h3>
</h3>
<Label
v-if="isBeta && !isComingSoon"
v-tooltip.top="t('GENERAL.BETA_DESCRIPTION')"
:label="t('GENERAL.BETA')"
color="blue"
compact
/>
</div>
<p class="text-n-slate-11 text-start text-sm"> <p class="text-n-slate-11 text-start text-sm">
{{ description }} {{ description }}
</p> </p>
@@ -67,7 +50,7 @@ const { t } = useI18n();
class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed" class="absolute inset-0 flex items-center justify-center backdrop-blur-[2px] rounded-2xl bg-gradient-to-br from-n-surface-1/90 via-n-surface-1/70 to-n-surface-1/95 cursor-not-allowed"
> >
<span class="text-n-slate-12 font-medium text-sm"> <span class="text-n-slate-12 font-medium text-sm">
{{ t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀 {{ $t('CHANNEL_SELECTOR.COMING_SOON') }} 🚀
</span> </span>
</div> </div>
</button> </button>
@@ -122,6 +122,8 @@ const {
onAssignAgent, onAssignAgent,
onAssignLabels, onAssignLabels,
onRemoveLabels, onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
} = useBulkActions(); } = useBulkActions();
const { const {
@@ -864,7 +866,7 @@ watch(conversationFilters, (newVal, oldVal) => {
<template> <template>
<div <div
class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1 relative" class="flex flex-col flex-shrink-0 conversations-list-wrap bg-n-surface-1"
:class="[ :class="[
{ hidden: !showConversationList }, { hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]', isOnExpandedLayout ? 'basis-full' : 'w-[340px] 2xl:w-[412px]',
@@ -922,14 +924,18 @@ watch(conversationFilters, (newVal, oldVal) => {
{{ $t('CHAT_LIST.LIST.404') }} {{ $t('CHAT_LIST.LIST.404') }}
</p> </p>
<ConversationBulkActions <ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations" :conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected" :all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes" :selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')" :show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')" :show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')" :show-snoozed-action="allSelectedConversationsStatus('snoozed')"
:class="isOnExpandedLayout && 'sm:!w-[24rem] !w-full'"
@select-all-conversations="toggleSelectAll" @select-all-conversations="toggleSelectAll"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/> />
<ConversationList <ConversationList
:conversation-list="conversationList" :conversation-list="conversationList"
@@ -102,14 +102,13 @@ export default {
return this.v$.editedValue.$error; return this.v$.editedValue.$error;
}, },
errorMessage() { errorMessage() {
if (this.v$.editedValue.url?.$invalid) { if (this.v$.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
} }
if (this.v$.editedValue.regexValidation?.$invalid) { if (!this.v$.editedValue.regexValidation) {
return ( return this.regexCue
this.regexCue || ? this.regexCue
this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT') : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
);
} }
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED'); return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
}, },
@@ -135,12 +134,9 @@ export default {
editedValue: { editedValue: {
required, required,
regexValidation: value => { regexValidation: value => {
if (!this.attributeRegex || !value) return true; return !(
try { this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
return getRegexp(this.attributeRegex).test(value); );
} catch {
return false;
}
}, },
}, },
}; };
@@ -1,63 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import Banner from 'dashboard/components-next/banner/Banner.vue';
const BANNER_COLOR_MAP = {
info: 'blue',
warning: 'amber',
error: 'ruby',
};
const { t } = useI18n();
const globalConfig = useMapGetter('globalConfig/get');
const dismissedBannerIds = ref(
LocalStorage.get(LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS) || []
);
const dismissKey = banner => `${banner.id}-${banner.updated_at}`;
const visibleBanners = computed(() => {
const banners = globalConfig.value?.activePlatformBanners || [];
return banners.filter(
banner => !dismissedBannerIds.value.includes(dismissKey(banner))
);
});
const formattedMessage = message =>
new MessageFormatter(message).formattedMessage;
const bannerColor = bannerType => BANNER_COLOR_MAP[bannerType] || 'slate';
const dismissBanner = banner => {
const key = dismissKey(banner);
if (dismissedBannerIds.value.includes(key)) return;
dismissedBannerIds.value.push(key);
LocalStorage.set(
LOCAL_STORAGE_KEYS.DISMISSED_PLATFORM_BANNERS,
dismissedBannerIds.value
);
};
</script>
<template>
<Banner
v-for="banner in visibleBanners"
:key="banner.id"
:color="bannerColor(banner.banner_type)"
:action-label="t('GENERAL_SETTINGS.DISMISS')"
class="!rounded-none !justify-center [&_.link]:underline [&_p]:m-0"
@action="dismissBanner(banner)"
>
<span
v-dompurify-html="formattedMessage(banner.banner_message)"
class="text-xs"
/>
</Banner>
</template>
@@ -1,5 +1,3 @@
<!-- DEPRECIATED -->
<!-- TODO: Replace this banner component with NextBanner "app/javascript/dashboard/components-next/banner/Banner.vue" -->
<script> <script>
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -77,10 +77,6 @@ const isComingSoon = computed(() => {
return ['voice'].includes(key) && !isActive.value; return ['voice'].includes(key) && !isActive.value;
}); });
const isBeta = computed(() => {
return ['tiktok', 'voice'].includes(props.channel.key);
});
const onItemClick = () => { const onItemClick = () => {
if (isActive.value) { if (isActive.value) {
emit('channelItemClick', props.channel.key); emit('channelItemClick', props.channel.key);
@@ -94,7 +90,6 @@ const onItemClick = () => {
:description="channel.description" :description="channel.description"
:icon="channel.icon" :icon="channel.icon"
:is-coming-soon="isComingSoon" :is-coming-soon="isComingSoon"
:is-beta="isBeta"
:disabled="!isActive" :disabled="!isActive"
@click="onItemClick" @click="onItemClick"
/> />
@@ -44,7 +44,6 @@ const handleEndCall = async () => {
await endCallSession({ await endCallSession({
conversationId: call.conversationId, conversationId: call.conversationId,
inboxId, inboxId,
callSid: call.callSid,
}); });
}; };
@@ -13,9 +13,6 @@ import {
triggerCharacters, triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin'; } from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image'; import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands'; import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list'; import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common'; import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
@@ -80,8 +77,6 @@ export default {
plugins: [ plugins: [
imagePastePlugin(this.handleImageUpload), imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(), this.createSlashPlugin(),
embedPreviewPlugin(markdownEmbeds),
trailingParagraphPlugin(),
], ],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false, showSlashMenu: false,
@@ -118,12 +113,6 @@ export default {
this.focusEditorInputField(); this.focusEditorInputField();
} }
}, },
beforeUnmount() {
if (editorView) {
editorView.destroy();
editorView = null;
}
},
methods: { methods: {
createSlashPlugin() { createSlashPlugin() {
return suggestionsPlugin({ return suggestionsPlugin({
@@ -499,9 +488,4 @@ export default {
max-height: 7.5rem; max-height: 7.5rem;
overflow: auto; overflow: auto;
} }
.ProseMirror .cw-embed-preview {
max-width: 36rem;
margin: 0.5rem 0 1rem;
}
</style> </style>
@@ -38,16 +38,10 @@ const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0); const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat)); const lastMessageInChat = computed(() => getLastMessage(props.chat));
const voiceCallData = computed(() => { const voiceCallData = computed(() => ({
const last = lastMessageInChat.value; status: props.chat.additional_attributes?.call_status,
if (last?.content_type !== 'voice_call' || !last.call) { direction: props.chat.additional_attributes?.call_direction,
return { status: null, direction: null }; }));
}
return {
status: last.call.status,
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
};
});
const showMetaSection = computed(() => { const showMetaSection = computed(() => {
return ( return (
@@ -13,8 +13,6 @@ import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox'; import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
const props = defineProps({ const props = defineProps({
chat: { chat: {
@@ -93,15 +91,6 @@ const hasMultipleInboxes = computed(
); );
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const copyConversationId = async () => {
try {
await copyTextToClipboard(String(props.chat.id));
useAlert(t('CONVERSATION.HEADER.COPY_ID_SUCCESS'));
} catch (error) {
// error
}
};
</script> </script>
<template> <template>
@@ -144,18 +133,9 @@ const copyConversationId = async () => {
</div> </div>
<div <div
class="flex items-center gap-1 overflow-hidden text-xs conversation--header--actions text-n-slate-11 text-ellipsis whitespace-nowrap" class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
> >
<button
type="button"
class="truncate text-label-small text-n-slate-11 hover:text-n-slate-12 !p-0 cucursor-pointer"
@click="copyConversationId"
>
{{ `#${chat.id}` }}
</button>
<span v-if="hasMultipleInboxes">•</span>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" /> <InboxName v-if="hasMultipleInboxes" :inbox="inbox" class="!mx-0" />
<span v-if="isSnoozed">•</span>
<span v-if="isSnoozed" class="font-medium text-n-amber-10"> <span v-if="isSnoozed" class="font-medium text-n-amber-10">
{{ snoozedDisplayText }} {{ snoozedDisplayText }}
</span> </span>
@@ -273,10 +273,6 @@ export default {
return MESSAGE_MAX_LENGTH.GENERAL; return MESSAGE_MAX_LENGTH.GENERAL;
}, },
showFileUpload() { showFileUpload() {
const { image_send: imageSend } =
this.currentChat?.additional_attributes?.tiktok_capabilities ?? {};
const tiktokAttachmentSupported = imageSend ?? true;
return ( return (
this.isAWebWidgetInbox || this.isAWebWidgetInbox ||
this.isAFacebookInbox || this.isAFacebookInbox ||
@@ -287,7 +283,7 @@ export default {
this.isATelegramChannel || this.isATelegramChannel ||
this.isALineChannel || this.isALineChannel ||
this.isAnInstagramChannel || this.isAnInstagramChannel ||
(this.isATiktokChannel && tiktokAttachmentSupported) this.isATiktokChannel
); );
}, },
replyButtonLabel() { replyButtonLabel() {
@@ -710,7 +706,6 @@ export default {
// Don't handle paste if editor is disabled // Don't handle paste if editor is disabled
if (this.isEditorDisabled) return; if (this.isEditorDisabled) return;
if (!this.showFileUpload && !this.isOnPrivateNote) return;
// Filter valid files (non-zero size) // Filter valid files (non-zero size)
Array.from(e.clipboardData.files) Array.from(e.clipboardData.files)
@@ -1030,8 +1025,6 @@ export default {
}); });
}, },
attachFile({ blob, file }) { attachFile({ blob, file }) {
if (!this.showFileUpload && !this.isOnPrivateNote) return;
const reader = new FileReader(); const reader = new FileReader();
reader.readAsDataURL(file.file); reader.readAsDataURL(file.file);
reader.onloadend = () => { reader.onloadend = () => {
@@ -22,10 +22,6 @@ const props = defineProps({
type: Array, type: Array,
required: true, required: true,
}, },
autoPlay: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['close']); const emit = defineEmits(['close']);
@@ -313,7 +309,6 @@ onMounted(() => {
:src="activeAttachment.data_url" :src="activeAttachment.data_url"
controls controls
playsInline playsInline
:autoplay="autoPlay"
class="max-h-full max-w-full object-contain" class="max-h-full max-w-full object-contain"
@click.stop @click.stop
/> />
@@ -322,7 +317,6 @@ onMounted(() => {
v-if="isAudio" v-if="isAudio"
:key="activeAttachment.message_id" :key="activeAttachment.message_id"
controls controls
:autoplay="autoPlay"
class="w-full max-w-md" class="w-full max-w-md"
@click.stop @click.stop
> >
@@ -0,0 +1,254 @@
<script>
import { mapGetters } from 'vuex';
import Avatar from 'next/avatar/Avatar.vue';
import Spinner from 'shared/components/Spinner.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
Avatar,
Spinner,
NextButton,
},
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
},
emits: ['select', 'close'],
data() {
return {
query: '',
selectedAgent: null,
goBackToAgentList: false,
};
},
computed: {
...mapGetters({
uiFlags: 'bulkActions/getUIFlags',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgents() {
if (this.query) {
return this.assignableAgents.filter(agent =>
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.selectedInboxes.join(',')
);
},
conversationLabel() {
return this.conversationCount > 1 ? 'conversations' : 'conversation';
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', this.selectedInboxes);
},
methods: {
submit() {
this.$emit('select', this.selectedAgent);
},
goBack() {
this.goBackToAgentList = true;
this.selectedAgent = null;
},
assignAgent(agent) {
this.selectedAgent = agent;
},
onClose() {
this.$emit('close');
},
onCloseAgentList() {
if (this.selectedAgent === null && !this.goBackToAgentList) {
this.onClose();
}
this.goBackToAgentList = false;
},
},
};
</script>
<template>
<div v-on-clickaway="onCloseAgentList" class="bulk-action__agents">
<div class="triangle">
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
</svg>
</div>
<div class="flex items-center justify-between header">
<span>{{ $t('BULK_ACTION.AGENT_SELECT_LABEL') }}</span>
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
</div>
<div class="container">
<div
v-if="assignableAgentsUiFlags.isFetching"
class="agent__list-loading"
>
<Spinner />
<p>{{ $t('BULK_ACTION.AGENT_LIST_LOADING') }}</p>
</div>
<div v-else class="agent__list-container">
<ul v-if="!selectedAgent">
<li class="search-container">
<div
class="flex items-center justify-between h-8 gap-2 agent-list-search"
>
<fluent-icon icon="search" class="search-icon" size="16" />
<input
v-model="query"
type="search"
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="reset-base !outline-0 !text-sm agent--search_input"
/>
</div>
</li>
<li v-for="agent in filteredAgents" :key="agent.id">
<div class="agent-list-item" @click="assignAgent(agent)">
<Avatar
:name="agent.name"
:src="agent.thumbnail"
:status="agent.availability_status"
:size="22"
hide-offline-status
rounded-full
/>
<span class="my-0 text-n-slate-12">
{{ agent.name }}
</span>
</div>
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<NextButton
faded
sm
slate
type="reset"
:label="$t('BULK_ACTION.GO_BACK_LABEL')"
@click="goBack"
/>
<NextButton
sm
type="submit"
:label="$t('BULK_ACTION.YES')"
:is-loading="uiFlags.isUpdating"
@click="submit"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.bulk-action__agents {
@apply max-w-[75%] absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply overflow-y-auto max-h-[15rem];
.agent__list-container {
@apply h-full;
}
.agent-list-search {
@apply py-0 px-2.5 bg-n-alpha-black2 border border-solid border-n-strong rounded-md;
.search-icon {
@apply text-n-slate-10;
}
.agent--search_input {
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
}
}
}
.triangle {
@apply block z-10 absolute -top-3 text-left ltr:right-[--triangle-position] rtl:left-[--triangle-position];
svg path {
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
}
}
}
ul {
@apply m-0 list-none;
li {
&:last-child {
.agent-list-item {
@apply last:rounded-b-lg;
}
}
}
}
.agent-list-item {
@apply flex items-center p-2.5 gap-2 cursor-pointer hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
span {
@apply text-sm;
}
}
.agent-confirmation-container {
@apply flex flex-col h-full p-2.5;
p {
@apply flex-grow;
}
.agent-confirmation-actions {
@apply w-full grid grid-cols-2 gap-2.5;
}
}
.search-container {
@apply py-0 px-2.5 sticky top-0 z-20 bg-n-alpha-3 backdrop-blur-[100px];
}
.agent__list-loading {
@apply m-2.5 rounded-md dark:bg-n-solid-3 bg-n-slate-2 flex items-center justify-center flex-col p-5 h-[calc(95%-6.25rem)];
}
</style>
@@ -1,202 +0,0 @@
<script setup>
import { useTemplateRef, computed, ref } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedAgent = ref(null);
const assignableAgentsUiFlags = useMapGetter(
'inboxAssignableAgents/getUIFlags'
);
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isLoading = computed(() => assignableAgentsUiFlags.value.isFetching);
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const assignableAgentsList = useMapGetter(
'inboxAssignableAgents/getAssignableAgents'
);
const assignableAgents = computed(() =>
assignableAgentsList.value(props.selectedInboxes.join(','))
);
const agentMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.NONE'),
thumbnail: {
name: t('BULK_ACTION.NONE'),
src: '',
},
isSelected: selectedAgent.value?.id === null,
},
];
assignableAgents.value.forEach(agent => {
items.push({
action: 'select',
value: agent.id,
label: agent.name,
thumbnail: {
name: agent.name,
src: agent.thumbnail,
},
isSelected: selectedAgent.value?.id === agent.id,
});
});
return items;
});
const handleSelectAgent = item => {
if (item.value === 'none') {
selectedAgent.value = { id: null, name: t('BULK_ACTION.NONE') };
} else {
const agent = assignableAgents.value.find(a => a.id === item.value);
selectedAgent.value = agent || { id: null, name: t('BULK_ACTION.NONE') };
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedAgent.value);
selectedAgent.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedAgent.value = null;
};
const handleDismiss = () => {
selectedAgent.value = null;
toggleDropdown(false);
};
const handleToggleDropdown = () => {
const willOpen = !showDropdown.value;
toggleDropdown();
// Fetch agents only when opening the dropdown
if (willOpen && props.selectedInboxes.length > 0) {
store.dispatch('inboxAssignableAgents/fetch', props.selectedInboxes);
}
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
icon="i-lucide-user-round-check"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="handleToggleDropdown"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="agentMenuItems"
:is-loading="isLoading"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-10 rtl:-left-10 ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-60 max-h-80"
@action="handleSelectAgent"
>
<template v-if="selectedAgent" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedAgent.id"
keypath="BULK_ACTION.ASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #agentName>
<strong class="text-n-slate-12">
{{ selectedAgent.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.UNASSIGN_AGENT_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,160 +0,0 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
type: {
type: String,
default: 'conversation',
},
isLoading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['assign']);
const { t } = useI18n();
const labels = useMapGetter('labels/getLabels');
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedLabels = ref([]);
const isTypeContact = computed(() => props.type === 'contact');
const buttonLabel = computed(() =>
props.type === 'contact' ? t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS') : ''
);
const isLabelSelected = labelTitle => {
return selectedLabels.value.includes(labelTitle);
};
const labelMenuItems = computed(() => {
return labels.value.map(label => ({
action: 'select',
value: label.title,
label: label.title,
color: label.color,
id: label.id,
isSelected: isLabelSelected(label.title),
}));
});
const toggleLabelSelection = labelTitle => {
const index = selectedLabels.value.indexOf(labelTitle);
if (index > -1) {
selectedLabels.value.splice(index, 1);
} else {
selectedLabels.value.push(labelTitle);
}
};
const handleAssign = () => {
if (selectedLabels.value.length > 0) {
emit('assign', selectedLabels.value);
toggleDropdown(false);
selectedLabels.value = [];
}
};
const handleDismiss = () => {
selectedLabels.value = [];
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<NextButton
v-tooltip="isTypeContact ? '' : $t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
:label="buttonLabel"
icon="i-lucide-tag"
slate
:size="isTypeContact ? 'sm' : 'xs'"
ghost
:class="{
'bg-n-alpha-2': showDropdown,
'[&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline w-fit !text-n-blue-11 [&>span]:!text-n-blue-11 !px-2':
isTypeContact,
}"
:disabled="disabled || isLoading"
:is-loading="isLoading"
@click="toggleDropdown()"
/>
<Transition
:enter-active-class="
!isTypeContact
? 'transition-all duration-150 ease-out origin-bottom'
: 'transition-all duration-150 ease-out origin-top'
"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
:leave-active-class="
!isTypeContact
? 'transition-all duration-100 ease-in origin-bottom'
: 'transition-all duration-100 ease-in origin-top'
"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="labelMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="w-60 max-h-80"
:class="{
'ltr:-right-[6.5rem] rtl:-left-[6.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8':
!isTypeContact,
'ltr:right-0 rtl:left-0 mb-1 top-10': isTypeContact,
}"
@action="item => toggleLabelSelection(item.value)"
>
<template #thumbnail="{ item }">
<span
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
:style="{ backgroundColor: item.color }"
/>
</template>
<template #trailing-icon="{ item }">
<Icon
v-if="isLabelSelected(item.value)"
icon="i-lucide-check"
class="size-4 text-n-blue-11 flex-shrink-0"
/>
</template>
<template #footer>
<div
class="sticky bottom-0 rounded-b-md px-2 py-2 z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<NextButton
sm
class="w-full [&>span:nth-child(2)]:hidden md:[&>span:nth-child(2)]:inline-flex"
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
:disabled="!selectedLabels.length"
@click="handleAssign"
/>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,174 +0,0 @@
<script setup>
import { useTemplateRef, computed, ref, onMounted } from 'vue';
import { useI18n, I18nT } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { useStore } from 'vuex';
import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
conversationCount: {
type: Number,
required: true,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const store = useStore();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const selectedTeam = ref(null);
const teams = useMapGetter('teams/getTeams');
const bulkActionsUiFlags = useMapGetter('bulkActions/getUIFlags');
const isUpdating = computed(() => bulkActionsUiFlags.value.isUpdating);
const teamMenuItems = computed(() => {
const items = [
{
action: 'select',
value: 'none',
label: t('BULK_ACTION.TEAMS.NONE'),
isSelected: selectedTeam.value?.id === 0,
},
];
teams.value.forEach(team => {
items.push({
action: 'select',
value: team.id,
label: team.name,
isSelected: selectedTeam.value?.id === team.id,
});
});
return items;
});
const handleSelectTeam = item => {
if (item.value === 'none') {
selectedTeam.value = { id: 0, name: t('BULK_ACTION.TEAMS.NONE') };
} else {
const foundTeam = teams.value.find(team => team.id === item.value);
selectedTeam.value = foundTeam || {
id: 0,
name: t('BULK_ACTION.TEAMS.NONE'),
};
}
};
const handleAssign = () => {
if (isUpdating.value) return;
emit('select', selectedTeam.value);
selectedTeam.value = null;
toggleDropdown(false);
};
const handleCancel = () => {
selectedTeam.value = null;
};
const handleDismiss = () => {
selectedTeam.value = null;
toggleDropdown(false);
};
onMounted(() => {
store.dispatch('teams/get');
});
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
icon="i-lucide-users-round"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[handleDismiss, { ignore: [containerRef] }]"
:menu-items="teamMenuItems"
show-search
:search-placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
class="ltr:-right-2 rtl:-left-2 bottom-8 w-60 max-h-80"
@action="handleSelectTeam"
>
<template v-if="selectedTeam" #footer>
<div
class="pt-2 pb-2 px-2 border-t border-n-weak sticky bottom-0 rounded-b-md z-20 bg-n-alpha-3 backdrop-blur-[4px]"
>
<div class="flex flex-col gap-2">
<I18nT
v-if="selectedTeam.id"
keypath="BULK_ACTION.TEAMS.ASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
<template #teamName>
<strong class="text-n-slate-12">
{{ selectedTeam.name }}
</strong>
</template>
</I18nT>
<I18nT
v-else
keypath="BULK_ACTION.TEAMS.UNASSIGN_TEAM_CONFIRMATION_LABEL"
tag="p"
class="text-xs text-n-slate-11 px-1 mb-0"
:plural="props.conversationCount"
>
<template #n>
<strong class="text-n-slate-12">
{{ props.conversationCount }}
</strong>
</template>
</I18nT>
<div class="flex gap-2">
<Button
sm
faded
slate
class="flex-1"
:label="t('BULK_ACTION.CANCEL')"
@click="handleCancel"
/>
<Button
sm
class="flex-1"
:label="t('BULK_ACTION.YES')"
:disabled="isUpdating"
:is-loading="isUpdating"
@click="handleAssign"
/>
</div>
</div>
</div>
</template>
</DropdownMenu>
</Transition>
</div>
</template>
@@ -1,109 +0,0 @@
<script setup>
import { useTemplateRef, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['update']);
const { t } = useI18n();
const containerRef = useTemplateRef('containerRef');
const [showDropdown, toggleDropdown] = useToggle(false);
const updateMenuItems = computed(() => {
const items = [];
if (props.showResolve) {
items.push({
action: 'update',
value: 'resolved',
label: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
icon: 'i-lucide-check',
});
}
if (props.showReopen) {
items.push({
action: 'update',
value: 'open',
label: t('CONVERSATION.HEADER.REOPEN_ACTION'),
icon: 'i-lucide-redo',
});
}
if (props.showSnooze) {
items.push({
action: 'update',
value: 'snoozed',
label: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
icon: 'i-lucide-alarm-clock',
});
}
return items;
});
const handleUpdate = item => {
if (item.value === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emit('update', item.value);
}
toggleDropdown(false);
};
</script>
<template>
<div ref="containerRef" class="relative">
<Button
v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
icon="i-lucide-circle-fading-arrow-up"
slate
xs
ghost
:class="{ 'bg-n-alpha-2': showDropdown }"
@click="toggleDropdown()"
/>
<Transition
enter-active-class="transition-all duration-150 ease-out origin-bottom"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition-all duration-100 ease-in origin-bottom"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<DropdownMenu
v-if="showDropdown"
v-on-click-outside="[
() => toggleDropdown(false),
{ ignore: [containerRef] },
]"
:menu-items="updateMenuItems"
class="ltr:-right-[4.5rem] rtl:-left-[4.5rem] ltr:2xl:right-0 rtl:2xl:left-0 bottom-8 w-36"
@action="handleUpdate"
/>
</Transition>
</div>
</template>
@@ -1,9 +1,7 @@
<script setup> <script>
import { ref, computed, onMounted, onUnmounted, useAttrs } from 'vue';
import { getUnixTime } from 'date-fns'; import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions.js';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION, CMD_BULK_ACTION_SNOOZE_CONVERSATION,
@@ -12,181 +10,315 @@ import {
} from 'dashboard/helper/commandbar/events'; } from 'dashboard/helper/commandbar/events';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import AgentSelector from './AgentSelector.vue';
import BulkAgentActions from './BulkAgentActions.vue'; import UpdateActions from './UpdateActions.vue';
import BulkUpdateActions from './BulkUpdateActions.vue'; import LabelActions from './LabelActions.vue';
import BulkLabelActions from './BulkLabelActions.vue'; import TeamActions from './TeamActions.vue';
import BulkTeamActions from './BulkTeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
const props = defineProps({ components: {
conversations: { AgentSelector,
type: Array, UpdateActions,
default: () => [], LabelActions,
TeamActions,
CustomSnoozeModal,
NextButton,
}, },
allConversationsSelected: { props: {
type: Boolean, conversations: {
default: false, type: Array,
default: () => [],
},
allConversationsSelected: {
type: Boolean,
default: false,
},
selectedInboxes: {
type: Array,
default: () => [],
},
showOpenAction: {
type: Boolean,
default: false,
},
showResolvedAction: {
type: Boolean,
default: false,
},
showSnoozedAction: {
type: Boolean,
default: false,
},
}, },
selectedInboxes: { emits: [
type: Array, 'selectAllConversations',
default: () => [], 'assignAgent',
'updateConversations',
'assignLabels',
'assignTeam',
'resolveConversations',
],
data() {
return {
showAgentsList: false,
showUpdateActions: false,
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
}, },
showOpenAction: { mounted() {
type: Boolean, emitter.on(
default: false, CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
}, },
showResolvedAction: { unmounted() {
type: Boolean, emitter.off(
default: false, CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
}, },
showSnoozedAction: { methods: {
type: Boolean, onCmdSnoozeConversation(snoozeType) {
default: false, if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else if (typeof snoozeType === 'number') {
this.updateConversations('snoozed', snoozeType);
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('selectAllConversations', e.target.checked);
},
submit(agent) {
this.$emit('assignAgent', agent);
},
updateConversations(status, snoozedUntil) {
this.$emit('updateConversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assignLabels', labels);
},
assignTeam(team) {
this.$emit('assignTeam', team);
},
resolveConversations() {
this.$emit('resolveConversations');
},
toggleUpdateActions() {
this.showUpdateActions = !this.showUpdateActions;
},
toggleLabelActions() {
this.showLabelActions = !this.showLabelActions;
},
toggleAgentList() {
this.showAgentsList = !this.showAgentsList;
},
toggleTeamsList() {
this.showTeamsList = !this.showTeamsList;
},
}, },
}); };
const emit = defineEmits(['selectAllConversations']);
defineOptions({
inheritAttrs: false,
});
const attrs = useAttrs();
const {
onAssignAgent,
onAssignLabels,
onAssignTeamsForBulk: onAssignTeam,
onUpdateConversations,
} = useBulkActions();
const showCustomTimeSnoozeModal = ref(false);
function onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
showCustomTimeSnoozeModal.value = true;
} else if (typeof snoozeType === 'number') {
onUpdateConversations('snoozed', snoozeType);
} else {
onUpdateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
}
function onCmdReopenConversation() {
onUpdateConversations('open', null);
}
function onCmdResolveConversation() {
onUpdateConversations('resolved', null);
}
function customSnoozeTime(customSnoozedTime) {
showCustomTimeSnoozeModal.value = false;
if (customSnoozedTime) {
onUpdateConversations('snoozed', getUnixTime(customSnoozedTime));
}
}
function hideCustomSnoozeModal() {
showCustomTimeSnoozeModal.value = false;
}
// Computed property with getter/setter to enable v-model usage
const allSelected = computed({
get: () => props.allConversationsSelected,
set: value => {
emit('selectAllConversations', value);
},
});
onMounted(() => {
emitter.on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.on(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.on(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
onUnmounted(() => {
emitter.off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, onCmdSnoozeConversation);
emitter.off(CMD_BULK_ACTION_REOPEN_CONVERSATION, onCmdReopenConversation);
emitter.off(CMD_BULK_ACTION_RESOLVE_CONVERSATION, onCmdResolveConversation);
});
</script> </script>
<template> <template>
<Transition <div class="bulk-action__container">
enter-active-class="transition-all duration-200 ease-out origin-bottom" <div class="flex items-center justify-between">
enter-from-class="opacity-0 scale-95 translate-y-2" <label class="flex items-center justify-between bulk-action__panel">
enter-to-class="opacity-100 scale-100 translate-y-0" <input
leave-active-class="transition-all duration-150 ease-in origin-bottom" type="checkbox"
leave-from-class="opacity-100 scale-100 translate-y-0" class="checkbox"
leave-to-class="opacity-0 scale-95 translate-y-2" :checked="allConversationsSelected"
> :indeterminate.prop="!allConversationsSelected"
<div @change="selectAll($event)"
v-if="conversations.length > 0" />
v-bind="attrs" <span>
class="px-2 absolute bottom-20 sm:bottom-4 left-1/2 -translate-x-1/2 z-30 w-full origin-bottom" {{
> $t('BULK_ACTION.CONVERSATIONS_SELECTED', {
<div conversationCount: conversations.length,
v-if="allConversationsSelected" })
class="bg-n-amber-2 outline -outline-offset-1 outline-1 outline-n-amber-5 rounded-lg text-sm mb-2 py-1.5 px-2 text-n-amber-text" }}
> </span>
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }} </label>
</div> <div class="flex items-center gap-1 bulk-action__actions">
<div <NextButton
class="flex items-center justify-between p-2 bg-n-button-color outline outline-1 -outline-offset-1 rounded-[10px] outline-n-weak shadow-[0_0_12px_0_rgba(27,40,59,0.08)]" v-tooltip="$t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
> icon="i-lucide-tags"
<div class="ltr:ml-0.5 rtl:mr-0.5 flex items-center gap-1"> slate
<label class="cursor-pointer flex items-center gap-1.5"> xs
<Checkbox faded
v-model="allSelected" @click="toggleLabelActions"
:indeterminate="!allConversationsSelected" />
/> <NextButton
<span class="cursor-pointer"> v-tooltip="$t('BULK_ACTION.UPDATE.CHANGE_STATUS')"
{{ icon="i-lucide-repeat"
$t('BULK_ACTION.CONVERSATIONS_SELECTED', { slate
conversationCount: conversations.length, xs
}) faded
}} @click="toggleUpdateActions"
</span> />
</label> <NextButton
<div class="w-px h-3 bg-n-weak rounded-lg ltr:ml-1 rtl:mr-1" /> v-tooltip="$t('BULK_ACTION.ASSIGN_AGENT_TOOLTIP')"
<NextButton icon="i-lucide-user-round-plus"
:label="$t('BULK_ACTION.CLEAR_SELECTION')" slate
ghost xs
class="!text-n-blue-11 !px-1 !h-6" faded
sm @click="toggleAgentList"
@click="allSelected = false" />
/> <NextButton
</div> v-tooltip="$t('BULK_ACTION.ASSIGN_TEAM_TOOLTIP')"
<div class="flex items-center gap-2"> icon="i-lucide-users-round"
<BulkLabelActions @assign="onAssignLabels" /> slate
<BulkUpdateActions xs
:show-resolve="!showResolvedAction" faded
:show-reopen="!showOpenAction" @click="toggleTeamsList"
:show-snooze="!showSnoozedAction" />
@update="onUpdateConversations"
/>
<BulkAgentActions
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="onAssignAgent"
/>
<BulkTeamActions
:conversation-count="conversations.length"
@select="onAssignTeam"
/>
</div>
</div> </div>
<transition name="popover-animation">
<LabelActions
v-if="showLabelActions"
class="label-actions-box"
@assign="assignLabels"
@close="showLabelActions = false"
/>
</transition>
<transition name="popover-animation">
<UpdateActions
v-if="showUpdateActions"
class="update-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
:show-resolve="!showResolvedAction"
:show-reopen="!showOpenAction"
:show-snooze="!showSnoozedAction"
@update="updateConversations"
@close="showUpdateActions = false"
/>
</transition>
<transition name="popover-animation">
<AgentSelector
v-if="showAgentsList"
class="agent-actions-box"
:selected-inboxes="selectedInboxes"
:conversation-count="conversations.length"
@select="submit"
@close="showAgentsList = false"
/>
</transition>
<transition name="popover-animation">
<TeamActions
v-if="showTeamsList"
class="team-actions-box"
@assign-team="assignTeam"
@close="showTeamsList = false"
/>
</transition>
</div> </div>
</Transition> <div v-if="allConversationsSelected" class="bulk-action__alert">
<woot-modal {{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
v-model:show="showCustomTimeSnoozeModal" </div>
:on-close="hideCustomSnoozeModal" <woot-modal
> v-model:show="showCustomTimeSnoozeModal"
<CustomSnoozeModal :on-close="hideCustomSnoozeModal"
@close="hideCustomSnoozeModal" >
@choose-time="customSnoozeTime" <CustomSnoozeModal
/> @close="hideCustomSnoozeModal"
</woot-modal> @choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template> </template>
<style scoped lang="scss">
.bulk-action__container {
@apply p-3 relative border-b border-solid border-n-strong dark:border-n-weak;
}
.bulk-action__panel {
@apply cursor-pointer;
span {
@apply text-xs my-0 mx-1;
}
input[type='checkbox'] {
@apply cursor-pointer m-0;
}
}
.bulk-action__alert {
@apply bg-n-amber-3 text-n-amber-12 rounded text-xs mt-2 py-1 px-2 border border-solid border-n-amber-5;
}
.popover-animation-enter-active,
.popover-animation-leave-active {
transition: transform ease-out 0.1s;
}
.popover-animation-enter {
transform: scale(0.95);
@apply opacity-0;
}
.popover-animation-enter-to {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave {
transform: scale(1);
@apply opacity-100;
}
.popover-animation-leave-to {
transform: scale(0.95);
@apply opacity-0;
}
.label-actions-box {
--triangle-position: 5.3125rem;
}
.update-actions-box {
--triangle-position: 3.5rem;
}
.agent-actions-box {
--triangle-position: 1.75rem;
}
.team-actions-box {
--triangle-position: 0.125rem;
}
</style>

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