Merge branch 'develop' into feat/seeder-csat

This commit is contained in:
Muhsin Keloth
2025-06-26 11:07:55 +05:30
committed by GitHub
903 changed files with 14282 additions and 2932 deletions
+8 -4
View File
@@ -172,6 +172,8 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
@@ -433,10 +435,12 @@ GEM
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (2.5.2)
launchy (3.1.1)
addressable (~> 2.8)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
@@ -563,7 +567,7 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
public_suffix (6.0.2)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
+7 -1
View File
@@ -44,6 +44,12 @@ force_run:
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
@@ -53,4 +59,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
+4
View File
@@ -0,0 +1,4 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+12 -1
View File
@@ -10,7 +10,8 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
textElement.textContent = '•'.repeat(10);
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -32,3 +33,13 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,17 +46,25 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 1;
flex: 0 0 auto;
}
button {
margin-left: 5px;
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
@@ -0,0 +1,103 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = contacts.page(@current_page)
@contacts = fetch_contacts(contacts)
end
def show; end
@@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private
def permitted_update_params
@@ -1,32 +1,23 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/',
scope: scope,
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
@@ -1,7 +1,6 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,5 +1,5 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
@@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -1,28 +1,19 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include MicrosoftConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
scope: scope,
state: state,
prompt: 'consent'
}
)
if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -0,0 +1,23 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
end
def check_signup_enabled
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder)
end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private
def check_authorization
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
def scope
'email profile https://mail.google.com/'
end
end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
def scope
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email'
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private
def ensure_html_format
head :not_acceptable unless request.format.html?
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json?
end
def set_global_config
+9 -8
View File
@@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -64,13 +63,10 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
@@ -85,12 +81,17 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
def account_id
::Redis::Alfred.get(cache_key)
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
end
def account
@account ||= Account.find(account_id)
@account ||= account_from_signed_id
end
# Fallback name, for when name field is missing from users_data
@@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
@@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
@@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
reports = V2::Reports::LabelSummaryBuilder.new(
account: Current.account,
params: build_params({})
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
end
end
+11 -11
View File
@@ -38,13 +38,7 @@ export default {
}
return false;
},
profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
profileUpdate({ displayName, avatar, ...profileAttributes }) {
const formData = new FormData();
Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined;
@@ -53,16 +47,22 @@ export default {
}
});
formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) {
formData.append('profile[avatar]', avatar);
}
return axios.put(endPoints('profileUpdate').url, formData);
},
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings },
+5
View File
@@ -61,6 +61,11 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -51,6 +51,7 @@ const endPoints = {
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
@@ -134,13 +134,13 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
}
}
export default new ConversationApi();
@@ -33,9 +33,11 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId) {
unlinkIssue(linkId, issueIdentifier, conversationId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}
@@ -91,6 +91,19 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -120,6 +133,18 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -164,12 +189,26 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
}
);
});
@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
},
});
}
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -47,7 +47,7 @@
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal;
@apply text-sm font-normal flex justify-between items-center;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -58,7 +58,7 @@
}
&::after {
@apply bottom-0 flex items-center justify-center text-center;
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
}
&.multiselect__option--highlight {
@@ -74,7 +74,7 @@
}
&.multiselect__option--highlight::after {
@apply bg-transparent;
@apply bg-transparent text-n-slate-12;
}
&.multiselect__option--selected {
@@ -17,6 +17,7 @@ const props = defineProps({
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
availabilityStatus: { type: String, default: null },
isExpanded: { type: Boolean, default: false },
isUpdating: { type: Boolean, default: false },
});
@@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id);
<template>
<CardLayout :key="id" layout="row">
<div class="flex items-center justify-start flex-1 gap-4">
<Avatar :name="name" :src="thumbnail" :size="48" rounded-full />
<Avatar
:name="name"
:src="thumbnail"
:size="48"
:status="availabilityStatus"
rounded-full
/>
<div class="flex flex-col gap-0.5 flex-1">
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
<span class="text-base font-medium truncate text-n-slate-12">
@@ -7,42 +7,16 @@ import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
type: Boolean,
default: true,
},
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
required: true,
},
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
},
activeOrdering: {
type: String,
default: '',
},
isSegmentsView: {
type: Boolean,
default: false,
},
hasActiveFilters: {
type: Boolean,
default: false,
},
isLabelView: {
type: Boolean,
default: false,
},
showSearch: { type: Boolean, default: true },
searchValue: { type: String, default: '' },
headerTitle: { type: String, required: true },
buttonLabel: { type: String, default: '' },
activeSort: { type: String, default: 'last_activity_at' },
activeOrdering: { type: String, default: '' },
isSegmentsView: { type: Boolean, default: false },
hasActiveFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -85,7 +59,7 @@ const emit = defineEmits([
</Input>
</div>
<div class="flex items-center gap-2">
<div v-if="!isLabelView" class="relative">
<div v-if="!isLabelView && !isActiveView" class="relative">
<Button
id="toggleContactsFilterButton"
:icon="
@@ -105,7 +79,12 @@ const emit = defineEmits([
<slot name="filter" />
</div>
<Button
v-if="hasActiveFilters && !isSegmentsView && !isLabelView"
v-if="
hasActiveFilters &&
!isSegmentsView &&
!isLabelView &&
!isActiveView
"
icon="i-lucide-save"
color="slate"
size="sm"
@@ -113,7 +92,7 @@ const emit = defineEmits([
@click="emit('createSegment')"
/>
<Button
v-if="isSegmentsView && !isLabelView"
v-if="isSegmentsView && !isLabelView && !isActiveView"
icon="i-lucide-trash"
color="slate"
size="sm"
@@ -36,6 +36,7 @@ const props = defineProps({
activeSegment: { type: Object, default: null },
hasAppliedFilters: { type: Boolean, default: false },
isLabelView: { type: Boolean, default: false },
isActiveView: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -277,6 +278,7 @@ defineExpose({
:header-title="headerTitle"
:is-segments-view="hasActiveSegments"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
:has-active-filters="hasAppliedFilters"
:button-label="t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
@search="emit('search', $event)"
@@ -6,7 +6,7 @@ import ContactListHeaderWrapper from 'dashboard/components-next/Contacts/Contact
import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/ContactsHeader/components/ContactsActiveFiltersPreview.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
const props = defineProps({
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
@@ -37,10 +37,23 @@ const isNotSegmentView = computed(() => {
return route.name !== 'contacts_dashboard_segments_index';
});
const isActiveView = computed(() => {
return route.name === 'contacts_dashboard_active';
});
const isLabelView = computed(
() => route.name === 'contacts_dashboard_labels_index'
);
const showActiveFiltersPreview = computed(() => {
return (
(props.hasAppliedFilters || !isNotSegmentView.value) &&
!props.isFetchingList &&
!isLabelView.value &&
!isActiveView.value
);
});
const updateCurrentPage = page => {
emit('update:currentPage', page);
};
@@ -57,7 +70,7 @@ const openFilter = () => {
<div class="flex flex-col w-full h-full transition-all duration-300">
<ContactListHeaderWrapper
ref="contactListHeaderWrapper"
:show-search="isNotSegmentView"
:show-search="isNotSegmentView && !isActiveView"
:search-value="searchValue"
:active-sort="activeSort"
:active-ordering="activeOrdering"
@@ -66,6 +79,7 @@ const openFilter = () => {
:segments-id="segmentsId"
:has-applied-filters="hasAppliedFilters"
:is-label-view="isLabelView"
:is-active-view="isActiveView"
@update:sort="emit('update:sort', $event)"
@search="emit('search', $event)"
@apply-filter="emit('applyFilter', $event)"
@@ -74,11 +88,7 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[60rem]">
<ContactsActiveFiltersPreview
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
v-if="showActiveFiltersPreview"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
@@ -71,6 +71,7 @@ const toggleExpanded = id => {
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
:availability-status="contact.availabilityStatus"
:is-expanded="expandedCardId === contact.id"
:is-updating="isUpdating"
@toggle="toggleExpanded(contact.id)"
@@ -56,7 +56,7 @@ useKeyboardEvents(keyboardEvents);
<template>
<div
class="flex flex-col justify-center items-center absolute top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
>
<Button
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
@@ -4,38 +4,14 @@ import { computed, ref, watch, useSlots } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
modelValue: { type: String, default: '' },
label: { type: String, default: '' },
placeholder: { type: String, default: '' },
focusOnMount: { type: Boolean, default: false },
maxLength: { type: Number, default: 200 },
showCharacterCount: { type: Boolean, default: true },
disabled: { type: Boolean, default: false },
message: { type: String, default: '' },
messageType: {
type: String,
default: 'info',
@@ -43,6 +19,7 @@ const props = defineProps({
},
enableVariables: { type: Boolean, default: false },
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:modelValue']);
@@ -120,6 +97,7 @@ watch(
:disabled="disabled"
:enable-variables="enableVariables"
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -76,7 +76,7 @@ const handlePageChange = event => {
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 xl:px-0">
<header class="sticky top-0 z-10 px-6">
<div class="w-full max-w-[60rem] mx-auto">
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
@@ -116,7 +116,7 @@ const handlePageChange = event => {
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto xl:px-0">
<main class="flex-1 px-6 overflow-y-auto">
<div class="w-full max-w-[60rem] h-full mx-auto py-4">
<slot v-if="!showPaywall" name="controls" />
<div
@@ -123,7 +123,7 @@ const handleDocumentableClick = () => {
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4">
<div v-show="selectable" class="absolute top-7 ltr:left-3 rtl:right-3">
<Checkbox v-model="modelValue" />
</div>
<div class="flex relative justify-between w-full gap-1">
@@ -1,6 +1,5 @@
<script setup>
import { nextTick, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { nextTick, ref, watch, computed } from 'vue';
import { useTrack } from 'dashboard/composables';
import { COPILOT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -9,23 +8,17 @@ import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import CopilotThinkingGroup from './CopilotThinkingGroup.vue';
import ToggleCopilotAssistant from './ToggleCopilotAssistant.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import CopilotEmptyState from './CopilotEmptyState.vue';
import SidebarActionsHeader from 'dashboard/components-next/SidebarActionsHeader.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: {
type: Array,
default: () => [],
},
isCaptainTyping: {
type: Boolean,
default: false,
},
conversationInboxType: {
type: String,
required: true,
@@ -44,18 +37,11 @@ const emit = defineEmits(['sendMessage', 'reset', 'setAssistant']);
const { t } = useI18n();
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
useTrack(COPILOT_EVENTS.SEND_MESSAGE);
};
const useSuggestion = opt => {
emit('sendMessage', t(opt.prompt));
useTrack(COPILOT_EVENTS.SEND_SUGGESTED);
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
@@ -65,20 +51,40 @@ const scrollToBottom = async () => {
}
};
const promptOptions = [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
];
const groupedMessages = computed(() => {
const result = [];
let thinkingGroup = [];
props.messages.forEach(message => {
if (message.message_type === 'assistant_thinking') {
thinkingGroup.push(message);
} else {
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
thinkingGroup = [];
}
result.push(message);
}
});
if (thinkingGroup.length > 0) {
result.push({
id: thinkingGroup[0].id,
message_type: 'thinking_group',
messages: thinkingGroup,
});
}
return result;
});
const isLastMessageFromAssistant = computed(() => {
return (
groupedMessages.value[groupedMessages.value.length - 1].message_type ===
'assistant'
);
});
const { updateUISettings } = useUISettings();
@@ -95,8 +101,22 @@ const handleSidebarAction = action => {
}
};
const hasAssistants = computed(() => props.assistants.length > 0);
const hasMessages = computed(() => props.messages.length > 0);
const copilotButtons = computed(() => {
if (hasMessages.value) {
return [
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: t('CAPTAIN.COPILOT.RESET'),
},
];
}
return [];
});
watch(
[() => props.messages, () => props.isCaptainTyping],
[() => props.messages],
() => {
scrollToBottom();
},
@@ -108,64 +128,57 @@ watch(
<div class="flex flex-col h-full text-sm leading-6 tracking-tight w-full">
<SidebarActionsHeader
:title="$t('CAPTAIN.COPILOT.TITLE')"
:buttons="[
{
key: 'reset',
icon: 'i-lucide-refresh-ccw',
tooltip: $t('CAPTAIN.COPILOT.RESET'),
},
]"
:buttons="copilotButtons"
@click="handleSidebarAction"
@close="closeCopilotPanel"
/>
<div ref="chatContainer" class="flex-1 px-4 py-4 space-y-6 overflow-y-auto">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
:conversation-inbox-type="conversationInboxType"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<div
v-if="!messages.length"
class="h-full w-full flex items-center justify-center"
ref="chatContainer"
class="flex-1 flex px-4 py-4 overflow-y-auto items-start"
>
<div class="h-fit px-3 py-3 space-y-1">
<span class="text-xs text-n-slate-10">
{{ $t('COPILOT.TRY_THESE_PROMPTS') }}
</span>
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="px-2 py-1 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center gap-1"
@click="() => useSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
<div v-if="hasMessages" class="space-y-6 flex-1 flex flex-col w-full">
<template v-for="(item, index) in groupedMessages" :key="item.id">
<CopilotAgentMessage
v-if="item.message_type === 'user'"
:message="item.message"
/>
<CopilotAssistantMessage
v-else-if="item.message_type === 'assistant'"
:message="item.message"
:is-last-message="index === groupedMessages.length - 1"
:conversation-inbox-type="conversationInboxType"
/>
<CopilotThinkingGroup
v-else
:messages="item.messages"
:default-collapsed="isLastMessageFromAssistant"
/>
</template>
<CopilotLoader v-if="!isLastMessageFromAssistant" />
</div>
<CopilotEmptyState
v-else
:has-assistants="hasAssistants"
@use-suggestion="sendMessage"
/>
</div>
<div class="mx-3 mt-px mb-2">
<div class="flex items-center gap-2 justify-between w-full mb-1">
<ToggleCopilotAssistant
v-if="assistants.length"
v-if="assistants.length > 1"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="$event => emit('setAssistant', $event)"
/>
<div v-else />
</div>
<CopilotInput class="mb-1 w-full" @send="sendMessage" />
<CopilotInput
v-if="hasAssistants"
class="mb-1 w-full"
@send="sendMessage"
/>
</div>
</div>
</template>
@@ -11,6 +11,10 @@ import MessageFormatter from 'shared/helpers/MessageFormatter.js';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
isLastMessage: {
type: Boolean,
default: false,
},
message: {
type: Object,
required: true,
@@ -20,6 +24,15 @@ const props = defineProps({
required: true,
},
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const showUseButton = computed(() => {
return (
!hasEmptyMessageContent.value &&
props.message.reply_suggestion &&
props.isLastMessage
);
});
const messageContent = computed(() => {
const formatter = new MessageFormatter(props.message.content);
@@ -32,8 +45,6 @@ const insertIntoRichEditor = computed(() => {
);
});
const hasEmptyMessageContent = computed(() => !props.message?.content);
const useCopilotResponse = () => {
if (insertIntoRichEditor.value) {
emitter.emit(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, props.message?.content);
@@ -57,7 +68,7 @@ const useCopilotResponse = () => {
/>
<div class="flex flex-row mt-1">
<Button
v-if="!hasEmptyMessageContent"
v-if="showUseButton"
:label="$t('CAPTAIN.COPILOT.USE')"
faded
sm
@@ -0,0 +1,106 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import Icon from '../icon/Icon.vue';
defineProps({
hasAssistants: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['useSuggestion']);
const { t } = useI18n();
const route = useRoute();
const routePromptMap = {
conversations: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUMMARIZE.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.SUGGEST.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.RATE.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.RATE.CONTENT',
},
],
dashboard: [
{
label: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.HIGH_PRIORITY.CONTENT',
},
{
label: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.LABEL',
prompt: 'CAPTAIN.COPILOT.PROMPTS.LIST_CONTACTS.CONTENT',
},
],
};
const getCurrentRoute = () => {
const path = route.path;
if (path.includes('/conversations')) return 'conversations';
if (path.includes('/dashboard')) return 'dashboard';
return 'dashboard';
};
const promptOptions = computed(() => {
const currentRoute = getCurrentRoute();
return routePromptMap[currentRoute] || routePromptMap.conversations;
});
const handleSuggestion = opt => {
emit('useSuggestion', t(opt.prompt));
};
</script>
<template>
<div class="flex-1 flex flex-col gap-6 px-2">
<div class="flex flex-col space-y-4 py-4">
<Icon icon="i-woot-captain" class="text-n-slate-9 text-4xl" />
<div class="space-y-1">
<h3 class="text-base font-medium text-n-slate-12 leading-8">
{{ $t('CAPTAIN.COPILOT.PANEL_TITLE') }}
</h3>
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.COPILOT.KICK_OFF_MESSAGE') }}
</p>
</div>
</div>
<div v-if="!hasAssistants" class="w-full space-y-2">
<p class="text-sm text-n-slate-11 leading-6">
{{ $t('CAPTAIN.ASSISTANTS.NO_ASSISTANTS_AVAILABLE') }}
</p>
<router-link
:to="{
name: 'captain_assistants_index',
params: { accountId: route.params.accountId },
}"
class="text-n-slate-11 underline hover:text-n-slate-12"
>
{{ $t('CAPTAIN.ASSISTANTS.ADD_NEW') }}
</router-link>
</div>
<div v-else class="w-full space-y-2">
<span class="text-xs text-n-slate-10 block">
{{ $t('CAPTAIN.COPILOT.TRY_THESE_PROMPTS') }}
</span>
<div class="space-y-1">
<button
v-for="prompt in promptOptions"
:key="prompt.label"
class="w-full px-3 py-2 rounded-md border border-n-weak bg-n-slate-2 text-n-slate-11 flex items-center justify-between hover:bg-n-slate-3 transition-colors"
@click="handleSuggestion(prompt)"
>
<span>{{ t(prompt.label) }}</span>
<Icon icon="i-lucide-chevron-right" />
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import Button from 'dashboard/components-next/button/Button.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const isConversationRoute = computed(() => {
const CONVERSATION_ROUTES = [
'inbox_conversation',
'conversation_through_inbox',
'conversations_through_label',
'team_conversations_through_label',
'conversations_through_folders',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_participating',
'inbox_view_conversation',
];
return CONVERSATION_ROUTES.includes(route.name);
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotLauncher = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
return (
isCaptainEnabled &&
!uiSettings.value.is_copilot_panel_open &&
!isConversationRoute.value
);
});
const toggleSidebar = () => {
updateUISettings({
is_copilot_panel_open: !uiSettings.value.is_copilot_panel_open,
is_contact_sidebar_open: false,
});
};
</script>
<template>
<div v-if="showCopilotLauncher" class="fixed bottom-4 right-4 z-50">
<div class="rounded-full bg-n-alpha-2 p-1">
<Button
icon="i-woot-captain"
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
lg
@click="toggleSidebar"
/>
</div>
</div>
<template v-else />
</template>
@@ -17,7 +17,7 @@ defineProps({
icon="i-lucide-sparkles"
class="w-4 h-4 mt-0.5 flex-shrink-0 text-n-slate-9"
/>
<div class="text-sm text-n-slate-11">
<div class="text-sm text-n-slate-12">
{{ content }}
</div>
</div>
@@ -51,10 +51,10 @@ watch(
}"
>
<CopilotThinkingBlock
v-for="message in messages"
:key="message.id"
:content="message.content"
:reasoning="message.reasoning"
v-for="copilotMessage in messages"
:key="copilotMessage.id"
:content="copilotMessage.message.content"
:reasoning="copilotMessage.message.reasoning"
/>
</div>
</div>
@@ -74,6 +74,7 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle">
<Button
ref="triggerRef"
type="button"
sm
slate
:variant
@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({
const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: {
type: Array,
required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean,
default: false,
},
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
});
const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm
slate
faded
type="button"
:icon="selectedItem.icon"
:label="selectedItem.name"
@click="toggle"
/>
<Button v-else sm slate faded @click="toggle">
<Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" />
<Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span>
<span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button>
</template>
<DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm"
autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/>
</div>
<DropdownSection class="max-h-80 overflow-scroll">
@@ -13,6 +13,7 @@ export function useChannelIcon(inbox) {
'Channel::WebWidget': 'i-ri-global-fill',
'Channel::Whatsapp': 'i-ri-whatsapp-fill',
'Channel::Instagram': 'i-ri-instagram-fill',
'Channel::Voice': 'i-ri-phone-fill',
};
const providerIconMap = {
@@ -19,6 +19,12 @@ describe('useChannelIcon', () => {
expect(icon).toBe('i-ri-whatsapp-fill');
});
it('returns correct icon for Voice channel', () => {
const inbox = { channel_type: 'Channel::Voice' };
const { value: icon } = useChannelIcon(inbox);
expect(icon).toBe('i-ri-phone-fill');
});
describe('Email channel', () => {
it('returns mail icon for generic email channel', () => {
const inbox = { channel_type: 'Channel::Email' };
@@ -1,7 +1,8 @@
<script setup>
import { computed, ref } from 'vue';
import { computed, watch } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({
min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
const convertToMinutes = newValue => {
if (unit.value === DURATION_UNITS.MINUTES) {
return Math.floor(newValue);
}
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === DURATION_UNITS.HOURS)
return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
let minuteValue = convertToMinutes(newValue);
duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
},
});
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script>
<template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
<option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
<option :value="DURATION_UNITS.HOURS">
{{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>
@@ -1,51 +1,21 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { computed, ref, onMounted, nextTick, getCurrentInstance } from 'vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
type: {
type: String,
default: 'text',
},
customInputClass: {
type: [String, Object, Array],
default: '',
},
placeholder: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
id: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
disabled: {
type: Boolean,
default: false,
},
modelValue: { type: [String, Number], default: '' },
type: { type: String, default: 'text' },
customInputClass: { type: [String, Object, Array], default: '' },
placeholder: { type: String, default: '' },
label: { type: String, default: '' },
id: { type: String, default: '' },
message: { type: String, default: '' },
disabled: { type: Boolean, default: false },
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
autofocus: {
type: Boolean,
default: false,
},
min: { type: String, default: '' },
autofocus: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -56,6 +26,10 @@ const emit = defineEmits([
'enter',
]);
// Generate a unique ID per component instance when `id` prop is not provided.
const { uid } = getCurrentInstance();
const uniqueId = computed(() => props.id || `input-${uid}`);
const isFocused = ref(false);
const inputRef = ref(null);
@@ -111,7 +85,7 @@ onMounted(() => {
<div class="relative flex flex-col min-w-0 gap-1">
<label
v-if="label"
:for="id"
:for="uniqueId"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
@@ -119,7 +93,7 @@ onMounted(() => {
<!-- Added prefix slot to allow adding icons to the input -->
<slot name="prefix" />
<input
:id="id"
:id="uniqueId"
ref="inputRef"
:value="modelValue"
:class="[
@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
@@ -379,7 +379,7 @@ const shouldRenderMessage = computed(() => {
function openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
['a', 'img'].includes(e.target?.tagName.toLowerCase());
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
@@ -14,7 +14,8 @@ const fromEmail = computed(() => {
});
const toEmail = computed(() => {
return contentAttributes.value?.email?.to ?? [];
const { toEmails, email } = contentAttributes.value;
return email?.to ?? toEmails ?? [];
});
const ccEmail = computed(() => {
@@ -4,9 +4,11 @@ import BaseBubble from './Base.vue';
import { useI18n } from 'vue-i18n';
import { CONTENT_TYPES } from '../constants.js';
import { useMessageContext } from '../provider.js';
import { useInbox } from 'dashboard/composables/useInbox';
const { content, contentAttributes, contentType } = useMessageContext();
const { t } = useI18n();
const { isAWebWidgetInbox } = useInbox();
const formValues = computed(() => {
if (contentType.value === CONTENT_TYPES.FORM) {
@@ -56,7 +58,7 @@ const formValues = computed(() => {
<dd>{{ item.title }}</dd>
</template>
</dl>
<div v-else class="my-2 font-medium">
<div v-else-if="isAWebWidgetInbox" class="my-2 font-medium">
{{ t('CONVERSATION.NO_RESPONSE') }}
</div>
</BaseBubble>
@@ -109,49 +109,58 @@ const downloadAudio = async () => {
</audio>
<div
v-bind="$attrs"
class="rounded-xl w-full gap-1 p-1.5 bg-n-alpha-white flex items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
class="rounded-xl w-full gap-2 p-1.5 bg-n-alpha-white flex flex-col items-center border border-n-container shadow-[0px_2px_8px_0px_rgba(94,94,94,0.06)]"
>
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
<div class="flex gap-1 w-full flex-1 items-center justify-start">
<button class="p-0 border-0 size-8" @click="playOrPause">
<Icon
v-if="isPlaying"
class="size-8"
icon="i-teenyicons-pause-small-solid"
/>
<Icon v-else class="size-8" icon="i-teenyicons-play-small-solid" />
</button>
<div class="tabular-nums text-xs">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
<div class="flex-1 items-center flex px-2">
<input
type="range"
min="0"
:max="duration"
:value="currentTime"
class="w-full h-1 bg-n-slate-12/40 rounded-lg appearance-none cursor-pointer accent-current"
@input="seek"
/>
<div
v-if="attachment.transcribedText"
class="text-n-slate-12 p-3 text-sm bg-n-alpha-1 rounded-lg w-full break-words"
>
{{ attachment.transcribedText }}
</div>
<button
class="border-0 w-10 h-6 grid place-content-center bg-n-alpha-2 hover:bg-alpha-3 rounded-2xl"
@click="changePlaybackSpeed"
>
<span class="text-xs text-n-slate-11 font-medium">
{{ playbackSpeedLabel }}
</span>
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="toggleMute"
>
<Icon v-if="isMuted" class="size-4" icon="i-lucide-volume-off" />
<Icon v-else class="size-4" icon="i-lucide-volume-2" />
</button>
<button
class="p-0 border-0 size-8 grid place-content-center"
@click="downloadAudio"
>
<Icon class="size-4" icon="i-lucide-download" />
</button>
</div>
</template>
@@ -87,7 +87,7 @@ const newReportRoutes = () => [
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
to: accountScopedRoute('label_reports_index'),
},
{
name: 'Reports Inbox',
@@ -235,6 +235,12 @@ const menuItems = computed(() => {
),
activeOn: ['contacts_dashboard_index', 'contacts_edit'],
},
{
name: 'Active',
label: t('SIDEBAR.ACTIVE'),
to: accountScopedRoute('contacts_dashboard_active'),
activeOn: ['contacts_dashboard_active'],
},
{
name: 'Segments',
icon: 'i-lucide-group',
@@ -17,7 +17,7 @@ export default {
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<img :src="src" :alt="title" draggable="false" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
@@ -22,6 +22,7 @@ import {
// https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import ChatListHeader from './ChatListHeader.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ConversationFilter from 'next/filter/ConversationFilter.vue';
import SaveCustomView from 'next/filter/SaveCustomView.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
@@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']);
const { uiSettings } = useUISettings();
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const store = useStore();
const conversationListRef = ref(null);
@@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() {
}
}
function redirectToConversationList() {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
}
async function assignPriority(priority, conversationId = null) {
store.dispatch('setCurrentChatPriority', {
priority,
@@ -670,26 +696,7 @@ async function markAsUnread(conversationId) {
await store.dispatch('markMessagesUnread', {
id: conversationId,
});
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = useRoute();
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
router.push(
conversationListPageURL({
accountId,
conversationType: conversationType,
customViewId: props.foldersId,
inboxId,
label,
teamId,
})
);
redirectToConversationList();
} catch (error) {
// Ignore error
}
@@ -703,6 +710,7 @@ async function markAsRead(conversationId) {
// Ignore error
}
}
async function onAssignTeam(team, conversationId = null) {
try {
await store.dispatch('assignTeam', {
@@ -748,6 +756,7 @@ function toggleSelectAll(check) {
}
useEmitter('fetch_conversation_stats', () => {
if (hasAppliedFiltersOrActiveFolders.value) return;
store.dispatch('conversationStats/get', conversationFilters.value);
});
@@ -764,6 +773,26 @@ onMounted(() => {
}
});
const deleteConversationDialogRef = ref(null);
const selectedConversationId = ref(null);
async function deleteConversation() {
try {
await store.dispatch('deleteConversation', selectedConversationId.value);
redirectToConversationList();
selectedConversationId.value = null;
deleteConversationDialogRef.value.close();
useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION'));
} catch (error) {
useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION'));
}
}
const handleDelete = conversationId => {
selectedConversationId.value = conversationId;
deleteConversationDialogRef.value.open();
};
provide('selectConversation', selectConversation);
provide('deSelectConversation', deSelectConversation);
provide('assignAgent', onAssignAgent);
@@ -775,6 +804,7 @@ provide('markAsUnread', markAsUnread);
provide('markAsRead', markAsRead);
provide('assignPriority', assignPriority);
provide('isConversationSelected', isConversationSelected);
provide('deleteConversation', handleDelete);
watch(activeTeam, () => resetAndFetchData());
@@ -824,6 +854,8 @@ watch(conversationFilters, (newVal, oldVal) => {
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
:is-on-expanded-layout="isOnExpandedLayout"
:conversation-stats="conversationStats"
:is-list-loading="chatListLoading && !conversationList.length"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@@ -938,6 +970,19 @@ watch(conversationFilters, (newVal, oldVal) => {
</template>
</DynamicScroller>
</div>
<Dialog
ref="deleteConversationDialogRef"
type="alert"
:title="
$t('CONVERSATION.DELETE_CONVERSATION.TITLE', {
conversationId: selectedConversationId,
})
"
:description="$t('CONVERSATION.DELETE_CONVERSATION.DESCRIPTION')"
:confirm-button-label="$t('CONVERSATION.DELETE_CONVERSATION.CONFIRM')"
@confirm="deleteConversation"
@close="selectedConversationId = null"
/>
<TeleportWithDirection
v-if="showAdvancedFilters"
to="#conversationFilterTeleportTarget"
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useMapGetter } from 'dashboard/composables/store.js';
import { formatNumber } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -10,26 +11,13 @@ import SwitchLayout from 'dashboard/routes/dashboard/conversation/search/SwitchL
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
isOnExpandedLayout: {
type: Boolean,
required: true,
},
pageTitle: { type: String, required: true },
hasAppliedFilters: { type: Boolean, required: true },
hasActiveFolders: { type: Boolean, required: true },
activeStatus: { type: String, required: true },
isOnExpandedLayout: { type: Boolean, required: true },
conversationStats: { type: Object, required: true },
isListLoading: { type: Boolean, required: true },
});
const emit = defineEmits([
@@ -62,6 +50,9 @@ const showV4View = computed(() => {
);
});
const allCount = computed(() => props.conversationStats?.allCount || 0);
const formattedAllCount = computed(() => formatNumber(allCount.value));
const toggleConversationLayout = () => {
const { LAYOUT_TYPES } = wootConstants;
const {
@@ -92,6 +83,15 @@ const toggleConversationLayout = () => {
>
{{ pageTitle }}
</h1>
<span
v-if="
allCount > 0 && hasAppliedFiltersOrActiveFolders && !isListLoading
"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
:title="allCount"
>
{{ formattedAllCount }}
</span>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="px-2 py-1 my-0.5 mx-1 rounded-md capitalize bg-n-slate-3 text-xxs text-n-slate-12 shrink-0"
@@ -16,6 +16,7 @@ export default {
'markAsRead',
'assignPriority',
'isConversationSelected',
'deleteConversation',
],
props: {
source: {
@@ -67,5 +68,6 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</template>
@@ -134,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div
class="rounded-lg shadow outline-1 outline"
class="rounded-lg shadow outline-1 outline flex-shrink-0"
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
>
<Button
@@ -178,7 +178,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 ltr:right-0 rtl:left-0 max-w-[12.5rem] min-w-[9.75rem]"
class="dropdown-pane dropdown-pane--open left-auto top-full mt-0.5 start-0 xl:start-auto xl:end-0 max-w-[12.5rem] min-w-[9.75rem]"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
@@ -1,16 +1,11 @@
<script setup>
import { ref, computed, onMounted, watchEffect } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import ConversationAPI from 'dashboard/api/inbox/conversation';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
defineProps({
conversationInboxType: {
type: String,
required: true,
@@ -20,12 +15,24 @@ const props = defineProps({
const store = useStore();
const currentUser = useMapGetter('getCurrentUser');
const assistants = useMapGetter('captainAssistants/getRecords');
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
const inboxAssistant = useMapGetter('getCopilotAssistant');
const { uiSettings, updateUISettings } = useUISettings();
const currentChat = useMapGetter('getSelectedChat');
const selectedCopilotThreadId = ref(null);
const messages = computed(() =>
store.getters['copilotMessages/getMessagesByThreadId'](
selectedCopilotThreadId.value
)
);
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const messages = ref([]);
const isCaptainTyping = ref(false);
const selectedAssistantId = ref(null);
const { uiSettings, updateUISettings } = useUISettings();
const activeAssistant = computed(() => {
const preferredId = uiSettings.value.preferred_captain_assistant_id;
@@ -55,68 +62,57 @@ const setAssistant = async assistant => {
});
};
const shouldShowCopilotPanel = computed(() => {
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
);
const { is_copilot_panel_open: isCopilotPanelOpen } = uiSettings.value;
return isCaptainEnabled && isCopilotPanelOpen && !uiFlags.value.fetchingList;
});
const handleReset = () => {
messages.value = [];
selectedCopilotThreadId.value = null;
};
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
isCaptainTyping.value = true;
try {
const { data } = await ConversationAPI.requestCopilot(
props.conversationId,
{
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
assistant_id: selectedAssistantId.value,
}
);
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
if (selectedCopilotThreadId.value) {
await store.dispatch('copilotMessages/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
threadId: selectedCopilotThreadId.value,
message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
} else {
const response = await store.dispatch('copilotThreads/create', {
assistant_id: activeAssistant.value.id,
conversation_id: currentChat.value?.id,
message,
});
selectedCopilotThreadId.value = response.id;
}
};
onMounted(() => {
store.dispatch('captainAssistants/get');
});
watchEffect(() => {
if (props.conversationId) {
store.dispatch('getInboxCaptainAssistantById', props.conversationId);
selectedAssistantId.value = activeAssistant.value?.id;
}
});
</script>
<template>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
<div
v-if="shouldShowCopilotPanel"
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 w-[320px] min-w-[320px] 2xl:min-w-[360px] 2xl:w-[360px] flex flex-col bg-n-background"
>
<Copilot
:messages="messages"
:support-agent="currentUser"
:conversation-inbox-type="conversationInboxType"
:assistants="assistants"
:active-assistant="activeAssistant"
@set-assistant="setAssistant"
@send-message="sendMessage"
@reset="handleReset"
/>
</div>
<template v-else />
</template>
@@ -9,6 +9,7 @@ const contacts = accountId => ({
'contacts_edit',
'contacts_edit_segment',
'contacts_edit_label',
'contacts_dashboard_active',
],
menuItems: [
{
@@ -18,6 +19,13 @@ const contacts = accountId => ({
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
{
icon: 'visitor-contacts',
label: 'ACTIVE',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts/active`),
toStateName: 'contacts_dashboard_active',
},
],
});
@@ -21,7 +21,7 @@ const props = defineProps({
const isRelaxed = computed(() => props.type === 'relaxed');
const headerClass = computed(() =>
isRelaxed.value
? 'first:rounded-bl-lg first:rounded-tl-lg last:rounded-br-lg last:rounded-tr-lg'
? 'ltr:first:rounded-bl-lg ltr:first:rounded-tl-lg ltr:last:rounded-br-lg ltr:last:rounded-tr-lg rtl:first:rounded-br-lg rtl:first:rounded-tr-lg rtl:last:rounded-bl-lg rtl:last:rounded-tl-lg'
: ''
);
</script>
@@ -41,6 +41,10 @@ export default {
);
}
if (key === 'voice') {
return this.enabledFeatures.channel_voice;
}
return [
'website',
'twilio',
@@ -50,6 +54,7 @@ export default {
'telegram',
'line',
'instagram',
'voice',
].includes(key);
},
},
@@ -25,10 +25,7 @@ defineProps({
:username="user.name"
:status="user.availability_status"
/>
<span
class="my-0 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
:class="textClass"
>
<span class="my-0 truncate text-capitalize" :class="textClass">
{{ user.name }}
</span>
</div>
@@ -78,6 +78,7 @@ export default {
'markAsRead',
'assignPriority',
'updateConversationStatus',
'deleteConversation',
],
data() {
return {
@@ -237,6 +238,10 @@ export default {
this.$emit('assignPriority', priority, this.chat.id);
this.closeContextMenu();
},
async deleteConversation() {
this.$emit('deleteConversation', this.chat.id);
this.closeContextMenu();
},
},
};
</script>
@@ -363,6 +368,7 @@ export default {
@mark-as-unread="markAsUnread"
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
/>
</ContextMenu>
</div>
@@ -11,8 +11,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
@@ -36,12 +34,6 @@ const { isAWebWidgetInbox } = useInbox();
const currentChat = computed(() => store.getters.getSelectedChat);
const accountId = computed(() => store.getters.getCurrentAccountId);
const isFeatureEnabledonAccount = computed(
() => store.getters['accounts/isFeatureEnabledonAccount']
);
const appIntegrations = computed(
() => store.getters['integrations/getAppIntegrations']
);
const chatMetadata = computed(() => props.chat.meta);
@@ -92,25 +84,15 @@ const hasMultipleInboxes = computed(
);
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
const isLinearIntegrationEnabled = computed(() =>
appIntegrations.value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
)
);
const isLinearFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR)
);
</script>
<template>
<div
ref="conversationHeader"
class="flex flex-col items-center justify-center flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
class="flex flex-col gap-3 items-center justify-between flex-1 w-full min-w-0 xl:flex-row px-3 py-2 border-b bg-n-background border-n-weak h-24 xl:h-12"
>
<div
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0"
class="flex items-center justify-start w-full xl:w-auto max-w-full min-w-0 xl:flex-1"
>
<BackButton
v-if="showBackButton"
@@ -122,11 +104,12 @@ const isLinearFeatureEnabled = computed(() =>
:username="currentContact.name"
:status="currentContact.availability_status"
size="32px"
class="flex-shrink-0"
/>
<div
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2 w-fit"
class="flex flex-col items-start min-w-0 ml-2 overflow-hidden rtl:ml-0 rtl:mr-2"
>
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0 w-fit">
<div class="flex flex-row items-center max-w-full gap-1 p-0 m-0">
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
@@ -136,7 +119,7 @@ const isLinearFeatureEnabled = computed(() =>
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
size="14"
class="text-n-amber-10 my-0 mx-0 min-w-[14px]"
class="text-n-amber-10 my-0 mx-0 min-w-[14px] flex-shrink-0"
icon="warning"
/>
</div>
@@ -152,20 +135,14 @@ const isLinearFeatureEnabled = computed(() =>
</div>
</div>
<div
class="flex flex-row items-center justify-start xl:justify-end flex-grow gap-2 w-full xl:w-auto mt-3 header-actions-wrap xl:mt-0"
class="flex flex-row items-center justify-start xl:justify-end flex-shrink-0 gap-2 w-full xl:w-auto header-actions-wrap"
>
<SLACardLabel
v-if="hasSlaPolicyId"
:chat="chat"
show-extended-info
:parent-width="width"
class="hidden lg:flex"
/>
<Linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
:parent-width="width"
class="hidden lg:flex"
class="hidden md:flex"
/>
<MoreActions :conversation-id="currentChat.id" />
</div>
@@ -1,43 +1,23 @@
<script setup>
import { computed } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { FEATURE_FLAGS } from '../../../featureFlags';
import { useUISettings } from 'dashboard/composables/useUISettings';
const props = defineProps({
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const channelType = computed(() => props.currentChat?.meta?.channel || '');
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const showCopilotTab = computed(() =>
isFeatureEnabledonAccount.value(currentAccountId.value, FEATURE_FLAGS.CAPTAIN)
);
const { uiSettings } = useUISettings();
const activeTab = computed(() => {
const {
is_contact_sidebar_open: isContactSidebarOpen,
is_copilot_panel_open: isCopilotPanelOpen,
} = uiSettings.value;
const { is_contact_sidebar_open: isContactSidebarOpen } = uiSettings.value;
if (isContactSidebarOpen) {
return 0;
}
if (isCopilotPanelOpen) {
return 1;
}
return null;
});
</script>
@@ -52,13 +32,6 @@ const activeTab = computed(() => {
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
/>
<CopilotContainer
v-show="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-inbox-type="channelType"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
</template>
@@ -134,7 +134,7 @@ onUnmounted(() => {
<SLAPopoverCard
v-if="showSlaPopoverCard"
:sla-missed-events="slaEvents"
class="rtl:left-0 ltr:right-0 top-7 hidden group-hover:flex"
class="start-0 xl:start-auto xl:end-0 top-7 hidden group-hover:flex"
/>
</div>
</template>
@@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
components: {
@@ -45,7 +46,14 @@ export default {
'assignAgent',
'assignTeam',
'assignLabel',
'deleteConversation',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
STATUS_TYPE: wootConstants.STATUS_TYPE,
@@ -121,6 +129,11 @@ export default {
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
};
},
computed: {
@@ -178,6 +191,9 @@ export default {
assignPriority(priority) {
this.$emit('assignPriority', priority);
},
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -277,5 +293,13 @@ export default {
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
variant="icon"
@click.stop="deleteConversation"
/>
</template>
</div>
</template>
@@ -183,13 +183,18 @@ const createIssue = async () => {
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
conversation_id: props.conversationId,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
const { identifier: issueIdentifier } = response.data;
await LinearAPI.link_issue(
props.conversationId,
issueIdentifier,
props.title
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
useTrack(LINEAR_EVENTS.CREATE_ISSUE);
onClose();
@@ -1,123 +0,0 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlinkIssue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-n-alpha-3 backdrop-blur-[100px] z-50 px-4 py-3 border border-solid border-n-container w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<UserAvatarWithName v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-n-slate-12">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-n-slate-12">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-n-slate-11">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -1,5 +1,4 @@
<script setup>
import { inject } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
@@ -15,8 +14,6 @@ const props = defineProps({
const emit = defineEmits(['unlinkIssue']);
const isUnlinking = inject('isUnlinking');
const unlinkIssue = () => {
emit('unlinkIssue');
};
@@ -27,36 +24,33 @@ const openIssue = () => {
</script>
<template>
<div class="flex flex-row justify-between">
<div class="flex items-center justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
class="flex items-center gap-2 px-2 py-1.5 border rounded-lg border-n-strong"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-1">
<div class="flex items-center gap-1">
<fluent-icon
icon="linear"
size="16"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-n-slate-12">
{{ identifier }}
</span>
</div>
<span class="w-px h-3 text-n-weak bg-n-weak" />
<Button
ghost
link
xs
slate
icon="i-lucide-unlink"
class="!transition-none"
:is-loading="isUnlinking"
@click="unlinkIssue"
/>
<Button
ghost
xs
slate
class="!transition-none"
icon="i-lucide-arrow-up-right"
class="!size-4"
@click="openIssue"
/>
</div>
<Button ghost xs slate icon="i-lucide-unlink" @click="unlinkIssue" />
</div>
</template>
@@ -0,0 +1,132 @@
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useAlert, useTrack } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import LinearIssueItem from './LinearIssueItem.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const getters = useStoreGetters();
const linkedIssues = ref([]);
const isLoading = ref(false);
const shouldShowCreateModal = ref(false);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(
() => getters.getConversationById.value(props.conversationId) || {}
);
const hasIssues = computed(() => linkedIssues.value.length > 0);
const loadLinkedIssues = async () => {
isLoading.value = true;
linkedIssues.value = [];
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
linkedIssues.value = response.data || [];
} catch (error) {
// Silent fail - not critical for UX
} finally {
isLoading.value = false;
}
};
const unlinkIssue = async (linkId, issueIdentifier) => {
try {
await LinearAPI.unlinkIssue(linkId, issueIdentifier, props.conversationId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssues.value = linkedIssues.value.filter(
issue => issue.id !== linkId
);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
}
};
const openCreateModal = () => {
shouldShowCreateModal.value = true;
};
const closeCreateModal = () => {
shouldShowCreateModal.value = false;
loadLinkedIssues();
};
watch(
() => props.conversationId,
() => {
loadLinkedIssues();
}
);
onMounted(() => {
loadLinkedIssues();
});
</script>
<template>
<div>
<div class="px-4 pt-3 pb-2">
<NextButton
ghost
xs
icon="i-lucide-plus"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')"
@click="openCreateModal"
/>
</div>
<div v-if="isLoading" class="flex justify-center p-8">
<Spinner />
</div>
<div v-else-if="!hasIssues" class="flex justify-center p-4">
<p class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.NO_LINKED_ISSUES') }}
</p>
</div>
<div v-else class="max-h-[300px] overflow-y-auto">
<LinearIssueItem
v-for="linkedIssue in linkedIssues"
:key="linkedIssue.id"
class="px-4 pt-3 pb-4 border-b border-n-weak last:border-b-0"
:linked-issue="linkedIssue"
@unlink-issue="unlinkIssue"
/>
</div>
<woot-modal
v-model:show="shouldShowCreateModal"
:on-close="closeCreateModal"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<CreateOrLinkIssue
:conversation="conversation"
:account-id="currentAccountId"
@close="closeCreateModal"
/>
</woot-modal>
</div>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
import IssueHeader from './IssueHeader.vue';
const props = defineProps({
linkedIssue: {
type: Object,
required: true,
},
});
const emit = defineEmits(['unlinkIssue']);
const { linkedIssue } = props;
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const issue = computed(() => linkedIssue.issue);
const assignee = computed(() => {
const assigneeDetails = issue.value.assignee;
if (!assigneeDetails) return null;
return {
name: assigneeDetails.name,
thumbnail: assigneeDetails.avatarUrl,
};
});
const labels = computed(() => issue.value.labels?.nodes || []);
const priorityLabel = computed(() => priorityMap[issue.value.priority]);
const unlinkIssue = () => {
emit('unlinkIssue', linkedIssue.id, linkedIssue.issue.identifier);
};
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col w-full">
<IssueHeader
:identifier="issue.identifier"
:link-id="linkedIssue.id"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<h3 class="mt-2 text-sm font-medium text-n-slate-12">
{{ issue.title }}
</h3>
<p
v-if="issue.description"
class="mt-1 text-sm text-n-slate-11 line-clamp-3"
>
{{ issue.description }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div v-if="assignee" class="flex items-center gap-1.5">
<Avatar :src="assignee.thumbnail" :name="assignee.name" :size="16" />
<span class="text-xs capitalize truncate text-n-slate-12">
{{ assignee.name }}
</span>
</div>
<div v-if="assignee" class="w-px h-3 bg-n-slate-4" />
<div class="flex items-center gap-1">
<Icon
icon="i-lucide-activity"
class="size-4"
:style="{ color: issue.state?.color }"
/>
<span class="text-xs text-n-slate-12">
{{ issue.state?.name }}
</span>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-n-slate-4" />
<div v-if="priorityLabel" class="flex items-center gap-1.5">
<CardPriorityIcon :priority="priorityLabel.toLowerCase()" />
<span class="text-xs text-n-slate-12">
{{ priorityLabel }}
</span>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,54 @@
<script setup>
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
const { isAdmin } = useAdmin();
const getters = useStoreGetters();
const accountId = getters.getCurrentAccountId;
const integrationId = 'linear';
const actionURL = computed(() =>
frontendURL(
`accounts/${accountId.value}/settings/integrations/${integrationId}`
)
);
const openLinearAccount = () => {
window.open(actionURL.value, '_blank');
};
</script>
<template>
<div class="flex flex-col p-3">
<div class="w-12 h-12 mb-3">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
class="object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:hidden dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
class="hidden object-contain w-full h-full border rounded-md shadow-sm border-n-weak dark:block"
/>
</div>
<div class="flex-1 mb-4">
<h3 class="mb-1.5 text-sm font-medium text-n-slate-12">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.TITLE') }}
</h3>
<p v-if="isAdmin" class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.DESCRIPTION') }}
</p>
<p v-else class="text-sm text-n-slate-11">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.AGENT_DESCRIPTION') }}
</p>
</div>
<NextButton v-if="isAdmin" faded slate @click="openLinearAccount">
{{ $t('INTEGRATION_SETTINGS.LINEAR.CTA.BUTTON_TEXT') }}
</NextButton>
</div>
</template>
@@ -63,7 +63,7 @@ const onSearch = async value => {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
id: issue.identifier,
name: `${issue.identifier} ${issue.title}`,
icon: 'status',
iconColor: issue.state.color,
@@ -1,161 +0,0 @@
<script setup>
import { computed, ref, onMounted, watch, defineOptions, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { useTrack } from 'dashboard/composables';
import { LINEAR_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
parentWidth: {
type: Number,
default: 10000,
},
});
defineOptions({
name: 'Linear',
});
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
useTrack(LINEAR_EVENTS.UNLINK_ISSUE);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const shouldShowIssueIdentifier = computed(() => {
if (!linkedIssue.value) {
return false;
}
return props.parentWidth > 600;
});
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
});
</script>
<template>
<div
class="relative after:content-[''] after:h-5 after:bg-transparent after:top-5 after:w-full after:block after:absolute after:z-0"
:class="{ group: linkedIssue }"
>
<Button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
sm
ghost
slate
class="!gap-1 group-hover:bg-n-alpha-2"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2] flex-shrink-0"
view-box="0 0 19 19"
/>
<span
v-if="shouldShowIssueIdentifier"
class="text-xs font-medium text-n-slate-11"
>
{{ linkedIssue.issue.identifier }}
</span>
</Button>
<Issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute rtl:left-0 ltr:right-0 top-9 invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
v-model:show="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<CreateOrLinkIssue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
@@ -45,9 +45,10 @@ export function useAccount() {
};
};
const updateAccount = async data => {
const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', {
...data,
options,
});
};
@@ -125,6 +125,10 @@ export const useInbox = () => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
@@ -142,5 +146,6 @@ export const useInbox = () => {
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isAVoiceChannel,
};
};
@@ -9,6 +9,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'contact_notes' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
{ name: 'shopify_orders' },
]);
@@ -33,6 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
'code',
];
export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [
'strong',
'em',
'link',
'undo',
'redo',
];
export const MESSAGE_EDITOR_IMAGE_RESIZES = [
{
name: 'Small',
@@ -36,6 +36,7 @@ const translationKeys = {
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
};
function extractAttrChange(attrChange) {
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') {
translationPayload = handleAccountUser(
auditLogItem,
+10
View File
@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
VOICE: 'Channel::Voice',
};
const INBOX_ICON_MAP_FILL = {
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
@@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
@@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
@@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
@@ -124,6 +131,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
@@ -59,6 +59,13 @@
"ERROR_MESSAGE": "Could not update bot. Please try again."
}
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
@@ -69,6 +69,9 @@
},
"ACCOUNT": {
"EDIT": "{agentName} updated the account configuration (#{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
}
}
}
@@ -285,6 +285,7 @@
"HEADER": {
"TITLE": "Contacts",
"SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
@@ -457,6 +458,10 @@
"PLACEHOLDER": "Add Twitter"
}
}
},
"DELETE_CONTACT": {
"MESSAGE": "This action is permanent and irreversible.",
"BUTTON": "Delete now"
}
},
"DETAILS": {
@@ -466,7 +471,7 @@
"DELETE_CONTACT": "Delete contact",
"DELETE_DIALOG": {
"TITLE": "Confirm Deletion",
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
"DESCRIPTION": "Are you sure you want to delete this contact?",
"CONFIRM": "Yes, Delete",
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
@@ -555,7 +560,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"COMPOSE_NEW_CONVERSATION": {
@@ -70,6 +70,7 @@
"RESOLVE_ACTION": "Resolve",
"REOPEN_ACTION": "Reopen",
"OPEN_ACTION": "Open",
"MORE_ACTIONS": "More actions",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details",
@@ -117,6 +118,11 @@
"FAILED": "Couldn't change priority. Please try again."
}
},
"DELETE_CONVERSATION": {
"TITLE": "Delete conversation #{conversationId}",
"DESCRIPTION": "Are you sure you want to delete this conversation?",
"CONFIRM": "Delete"
},
"CARD_CONTEXT_MENU": {
"PENDING": "Mark as pending",
"RESOLVED": "Mark as resolved",
@@ -133,6 +139,7 @@
"ASSIGN_LABEL": "Assign label",
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -207,6 +214,8 @@
"ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully",
"ASSIGN_LABEL_FAILED": "Label assignment failed",
"CHANGE_TEAM": "Conversation team changed",
"SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully",
"FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
@@ -299,6 +308,7 @@
"CONTACT_ATTRIBUTES": "Contact Attributes",
"PREVIOUS_CONVERSATION": "Previous Conversations",
"MACROS": "Macros",
"LINEAR_ISSUES": "Linked Linear Issues",
"SHOPIFY_ORDERS": "Shopify Orders"
},
"SHOPIFY": {
@@ -4,6 +4,7 @@
"PHONE_INPUT": {
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
}
},
"CLOSE": "Close"
}
}
@@ -46,7 +46,31 @@
},
"AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations",
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agents reply"
},
"UPDATE_BUTTON": "Save Changes"
},
"NAME": {
"LABEL": "Account name",
@@ -70,7 +94,15 @@
},
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agents reply."
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply."
},
"AUDIO_TRANSCRIPTION": {
"TITLE": "Transcribe Audio Messages",
"NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.",
"API": {
"SUCCESS": "Audio transcription setting updated successfully",
"ERROR": "Failed to update audio transcription setting"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
@@ -318,11 +318,18 @@
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
},
"NO_LINKED_ISSUES": "No linked issues found",
"DELETE": {
"TITLE": "Are you sure you want to delete the integration?",
"MESSAGE": "Are you sure you want to delete the integration?",
"CONFIRM": "Yes, delete",
"CANCEL": "Cancel"
},
"CTA": {
"TITLE": "Connect to Linear",
"AGENT_DESCRIPTION": "Linear workspace is not connected. Request your administrator to connect a workspace to use this integration.",
"DESCRIPTION": "Linear workspace is not connected. Click the button below to connect your workspace to use this integration.",
"BUTTON_TEXT": "Connect Linear workspace"
}
}
},
@@ -330,12 +337,17 @@
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"COPILOT": {
"TITLE": "Copilot",
"TRY_THESE_PROMPTS": "Try these prompts",
"PANEL_TITLE": "Get started with Copilot",
"KICK_OFF_MESSAGE": "Need a quick summary, want to check past conversations, or draft a better reply? Copilots here to speed things up.",
"SEND_MESSAGE": "Send message...",
"EMPTY_MESSAGE": "There was an error generating the response. Please try again.",
"LOADER": "Captain is thinking",
"YOU": "You",
"USE": "Use this",
"RESET": "Reset",
"SHOW_STEPS": "Show steps",
"SELECT_ASSISTANT": "Select Assistant",
"PROMPTS": {
"SUMMARIZE": {
@@ -349,6 +361,14 @@
"RATE": {
"LABEL": "Rate this conversation",
"CONTENT": "Review the conversation to see how well it meets the customer's needs. Share a rating out of 5 based on tone, clarity, and effectiveness."
},
"HIGH_PRIORITY": {
"LABEL": "High priority conversations",
"CONTENT": "Give me a summary of all high priority open conversations. Include the conversation ID, customer name (if available), last message content, and assigned agent. Group by status if relevant."
},
"LIST_CONTACTS": {
"LABEL": "List contacts",
"CONTENT": "Show me the list of top 10 contacts. Include name, email or phone number (if available), last seen time, tags (if any)."
}
}
},
@@ -368,7 +388,6 @@
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain AI feature is only available in a paid plan.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to our assistants, copilot and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
@@ -383,6 +402,7 @@
},
"ASSISTANTS": {
"HEADER": "Assistants",
"NO_ASSISTANTS_AVAILABLE": "There are no assistants available in your account.",
"ADD_NEW": "Create a new assistant",
"DELETE": {
"TITLE": "Are you sure to delete the assistant?",
@@ -411,6 +431,10 @@
"PLACEHOLDER": "Enter assistant name",
"ERROR": "The name is required"
},
"TEMPERATURE": {
"LABEL": "Response Temperature",
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter assistant description",
@@ -193,6 +193,7 @@
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
@@ -559,6 +560,7 @@
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
"LABEL": "Label",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
@@ -4,12 +4,14 @@
"ALL": "All",
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"SECTION": {
"CONTACTS": "Contacts",
"CONVERSATIONS": "Conversations",
"MESSAGES": "Messages"
"MESSAGES": "Messages",
"ARTICLES": "Articles"
},
"VIEW_MORE": "View more",
"LOAD_MORE": "Load more",
@@ -76,7 +76,12 @@
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy"
"COPY": "Copy",
"RESET": "Reset",
"CONFIRM_RESET": "Are you sure?",
"CONFIRM_HINT": "Click again to confirm",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
@@ -185,7 +190,8 @@
"OFFLINE": "Offline"
},
"SET_AVAILABILITY_SUCCESS": "Availability has been set successfully",
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again"
"SET_AVAILABILITY_ERROR": "Couldn't set availability, please try again",
"IMPERSONATING_ERROR": "Cannot change availability while impersonating a user"
},
"EMAIL": {
"LABEL": "Your email address",
@@ -280,6 +286,7 @@
"REPORTS": "Reports",
"SETTINGS": "Settings",
"CONTACTS": "Contacts",
"ACTIVE": "Active",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
@@ -59,6 +59,13 @@
"ERROR_MESSAGE": "تعذر تحديث الروبوت. يرجى المحاولة مرة أخرى."
}
},
"ACCESS_TOKEN": {
"TITLE": "رمز المصادقة",
"DESCRIPTION": "Copy the access token and save it securely",
"COPY_SUCCESSFUL": "Access token copied to clipboard",
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"FORM": {
"AVATAR": {
"LABEL": "Bot avatar"
@@ -69,6 +69,9 @@
},
"ACCOUNT": {
"EDIT": "{agentName} قام بتحديث إعدادات الحساب (##{id})"
},
"CONVERSATION": {
"DELETE": "{agentName} deleted conversation #{id}"
}
}
}
@@ -285,6 +285,7 @@
"HEADER": {
"TITLE": "جهات الاتصال",
"SEARCH_TITLE": "Search contacts",
"ACTIVE_TITLE": "Active contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "رسالة",
"SEND_MESSAGE": "إرسال الرسالة",
@@ -457,6 +458,10 @@
"PLACEHOLDER": "Add Twitter"
}
}
},
"DELETE_CONTACT": {
"MESSAGE": "This action is permanent and irreversible.",
"BUTTON": "Delete now"
}
},
"DETAILS": {
@@ -466,7 +471,7 @@
"DELETE_CONTACT": "حذف جهة الاتصال",
"DELETE_DIALOG": {
"TITLE": "تأكيد الحذف",
"DESCRIPTION": "Are you sure you want to delete this {contactName} contact?",
"DESCRIPTION": "Are you sure you want to delete this contact?",
"CONFIRM": "نعم، احذف",
"API": {
"SUCCESS_MESSAGE": "تم حذف جهة الاتصال بنجاح",
@@ -555,7 +560,8 @@
"SUBTITLE": "Start adding new contacts by clicking on the button below",
"BUTTON_LABEL": "Add contact",
"SEARCH_EMPTY_STATE_TITLE": "لا توجد جهات اتصال تطابق بحثك 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋",
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
}
},
"COMPOSE_NEW_CONVERSATION": {

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