Merge branch 'develop' into fix/contact-import-company-name-field
This commit is contained in:
@@ -41,7 +41,7 @@ class Email::BaseBuilder
|
||||
end
|
||||
|
||||
def business_name
|
||||
inbox.business_name || inbox.sanitized_name
|
||||
inbox.sanitized_business_name
|
||||
end
|
||||
|
||||
def account_support_email
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
@@ -11,10 +11,6 @@ class V2::Reports::AgentSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group('assignee_id').count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.account_users.map do |account_user|
|
||||
build_agent_stats(account_user)
|
||||
|
||||
@@ -9,37 +9,13 @@ class V2::Reports::BaseSummaryBuilder
|
||||
private
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
results = data_source.summary
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range)
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
# Override this method
|
||||
@conversations_count = results.transform_values { |data| data[:conversations_count] }
|
||||
@resolved_count = results.transform_values { |data| data[:resolved_conversations_count] }
|
||||
@avg_resolution_time = results.transform_values { |data| data[:avg_resolution_time] }
|
||||
@avg_first_response_time = results.transform_values { |data| data[:avg_first_response_time] }
|
||||
@avg_reply_time = results.transform_values { |data| data[:avg_reply_time] }
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
@@ -50,7 +26,26 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: nil,
|
||||
dimension_type: summary_dimension_type,
|
||||
dimension_id: nil,
|
||||
scope: nil,
|
||||
range: range,
|
||||
group_by: 'day',
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def summary_dimension_type
|
||||
{
|
||||
'account_id' => 'account',
|
||||
'user_id' => 'agent',
|
||||
'inbox_id' => 'inbox',
|
||||
'conversations.team_id' => 'team'
|
||||
}.fetch(group_by_key.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,23 +3,10 @@ class V2::Reports::Conversations::BaseReportBuilder
|
||||
|
||||
private
|
||||
|
||||
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
|
||||
COUNT_METRICS = %w[
|
||||
conversations_count
|
||||
incoming_messages_count
|
||||
outgoing_messages_count
|
||||
resolutions_count
|
||||
bot_resolutions_count
|
||||
bot_handoffs_count
|
||||
].freeze
|
||||
|
||||
def builder_class(metric)
|
||||
case metric
|
||||
when *AVG_METRICS
|
||||
V2::Reports::Timeseries::AverageReportBuilder
|
||||
when *COUNT_METRICS
|
||||
V2::Reports::Timeseries::CountReportBuilder
|
||||
end
|
||||
return unless Reports::ReportMetricRegistry.supported?(metric)
|
||||
|
||||
V2::Reports::Timeseries::ReportBuilder
|
||||
end
|
||||
|
||||
def log_invalid_metric
|
||||
|
||||
@@ -11,15 +11,6 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(group_by_key).count
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.inboxes.map do |inbox|
|
||||
build_inbox_stats(inbox)
|
||||
@@ -40,8 +31,4 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
def group_by_key
|
||||
:inbox_id
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]) ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,14 +6,6 @@ class V2::Reports::TeamSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
attr_reader :conversations_count, :resolved_count,
|
||||
:avg_resolution_time, :avg_first_response_time, :avg_reply_time
|
||||
|
||||
def fetch_conversations_count
|
||||
account.conversations.where(created_at: range).group(:team_id).count
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@reporting_events ||= account.reporting_events.where(created_at: range).joins(:conversation)
|
||||
end
|
||||
|
||||
def prepare_report
|
||||
account.teams.map do |team|
|
||||
build_team_stats(team)
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_average_time = reporting_events.average(average_value_key)
|
||||
grouped_event_count = reporting_events.count
|
||||
grouped_average_time.each_with_object([]) do |element, arr|
|
||||
event_date, average_time = element
|
||||
arr << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.average(average_value_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def event_name
|
||||
metric_to_event_name = {
|
||||
avg_first_response_time: :first_response,
|
||||
avg_resolution_time: :conversation_resolved,
|
||||
reply_time: :reply_time
|
||||
}
|
||||
metric_to_event_name[params[:metric].to_sym]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
scope.reporting_events.where(name: event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -1,12 +1,13 @@
|
||||
class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
include TimezoneHelper
|
||||
include DateRangeHelper
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
|
||||
pattr_initialize :account, :params
|
||||
|
||||
def scope
|
||||
case params[:type].to_sym
|
||||
case dimension_type.to_sym
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
@@ -20,6 +21,20 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
end
|
||||
end
|
||||
|
||||
def data_source
|
||||
@data_source ||= Reports::DataSource.for(
|
||||
account: account,
|
||||
metric: params[:metric],
|
||||
dimension_type: dimension_type,
|
||||
dimension_id: params[:id],
|
||||
scope: scope,
|
||||
range: range,
|
||||
group_by: group_by,
|
||||
timezone_offset: params[:timezone_offset],
|
||||
business_hours: params[:business_hours]
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -43,4 +58,10 @@ class V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dimension_type
|
||||
(params[:type].presence || 'account').to_s
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
grouped_count.each_with_object([]) do |element, arr|
|
||||
event_date, event_count = element
|
||||
|
||||
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
|
||||
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
|
||||
# because it converts the date to 12:00 AM server timezone.
|
||||
# The desired output should be 12:00 AM in the specified timezone.
|
||||
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
object_scope.count
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def metric
|
||||
@metric ||= params[:metric]
|
||||
end
|
||||
|
||||
def object_scope
|
||||
send("scope_for_#{metric}")
|
||||
end
|
||||
|
||||
def scope_for_conversations_count
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
end
|
||||
|
||||
def scope_for_incoming_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_outgoing_messages_count
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
end
|
||||
|
||||
def scope_for_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_resolutions_count
|
||||
scope.reporting_events.where(
|
||||
name: :conversation_bot_resolved,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
end
|
||||
|
||||
def scope_for_bot_handoffs_count
|
||||
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
|
||||
name: :conversation_bot_handoff,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
).distinct
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
# IMPORTANT: time_zone parameter affects both data grouping AND output timestamps
|
||||
# It converts timestamps to the target timezone before grouping, which means
|
||||
# the same event can fall into different day buckets depending on timezone
|
||||
# Example: 2024-01-15 00:00 UTC becomes 2024-01-14 16:00 PST (falls on different day)
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
class V2::Reports::Timeseries::ReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
|
||||
def timeseries
|
||||
data_source.timeseries
|
||||
end
|
||||
|
||||
def aggregate_value
|
||||
data_source.aggregate
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
class Api::V1::Accounts::Articles::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :set_articles, only: [:update_status, :delete_articles]
|
||||
|
||||
def translate
|
||||
head :not_implemented
|
||||
end
|
||||
|
||||
def update_status
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
return render_could_not_create_error(I18n.t('portals.articles.invalid_status')) unless Article.statuses.key?(params[:status])
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@articles.find_each { |article| article.update!(status: params[:status]) }
|
||||
end
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def delete_articles
|
||||
return render_could_not_create_error(I18n.t('portals.articles.no_articles_found')) if @articles.none?
|
||||
|
||||
@articles.destroy_all
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
authorize(Article, :create?)
|
||||
end
|
||||
|
||||
def set_articles
|
||||
@articles = @portal.articles.where(id: params[:ids])
|
||||
end
|
||||
end
|
||||
Api::V1::Accounts::Articles::BulkActionsController.prepend_mod_with('Api::V1::Accounts::Articles::BulkActionsController')
|
||||
@@ -3,15 +3,15 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label, :channel]
|
||||
|
||||
def agent
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder)
|
||||
render_report_with(V2::Reports::AgentSummaryBuilder, type: :agent)
|
||||
end
|
||||
|
||||
def team
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder)
|
||||
render_report_with(V2::Reports::TeamSummaryBuilder, type: :team)
|
||||
end
|
||||
|
||||
def inbox
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder)
|
||||
render_report_with(V2::Reports::InboxSummaryBuilder, type: :inbox)
|
||||
end
|
||||
|
||||
def label
|
||||
@@ -38,8 +38,9 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
|
||||
}
|
||||
end
|
||||
|
||||
def render_report_with(builder_class)
|
||||
builder = builder_class.new(account: Current.account, params: @builder_params)
|
||||
def render_report_with(builder_class, type: nil)
|
||||
builder_params = type.present? ? @builder_params.merge(type: type) : @builder_params
|
||||
builder = builder_class.new(account: Current.account, params: builder_params)
|
||||
render json: builder.build
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
class SuperAdmin::PushDiagnosticsController < SuperAdmin::ApplicationController
|
||||
def show
|
||||
@query = params[:user_query].to_s.strip
|
||||
@user = resolve_user(@query)
|
||||
@subscriptions = @user ? @user.notification_subscriptions.order(:id) : []
|
||||
@results = []
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(id: params[:user_id])
|
||||
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if @user.nil?
|
||||
|
||||
ids = parsed_subscription_ids
|
||||
if ids.empty?
|
||||
return redirect_to super_admin_push_diagnostics_path(user_query: @user.id),
|
||||
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_test')
|
||||
end
|
||||
|
||||
run_test_and_render(ids)
|
||||
end
|
||||
|
||||
def destroy_subscriptions
|
||||
user = User.find_by(id: params[:user_id])
|
||||
return redirect_to super_admin_push_diagnostics_path, alert: I18n.t('super_admin.push_diagnostics.user_not_found') if user.nil?
|
||||
|
||||
ids = parsed_subscription_ids
|
||||
if ids.empty?
|
||||
return redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||
alert: I18n.t('super_admin.push_diagnostics.no_subscriptions_to_delete')
|
||||
end
|
||||
|
||||
deleted_count = user.notification_subscriptions.where(id: ids).destroy_all.size
|
||||
log_super_admin_action("deleted #{deleted_count} subscriptions for user #{user.id}: #{ids}")
|
||||
redirect_to super_admin_push_diagnostics_path(user_query: user.id),
|
||||
notice: I18n.t('super_admin.push_diagnostics.subscriptions_deleted', count: deleted_count)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def run_test_and_render(ids)
|
||||
@query = @user.id.to_s
|
||||
@subscriptions = @user.notification_subscriptions.order(:id)
|
||||
@results = Notification::PushTestService.new(
|
||||
user: @user, subscription_ids: ids,
|
||||
title: params[:push_title], body: params[:push_body]
|
||||
).perform
|
||||
|
||||
log_super_admin_action("test sent for user #{@user.id} subscriptions #{ids}")
|
||||
render :show
|
||||
end
|
||||
|
||||
def log_super_admin_action(message)
|
||||
Rails.logger.info(
|
||||
"[SuperAdmin] push diagnostics #{message} " \
|
||||
"(actor_id=#{current_super_admin&.id}, actor_email=#{current_super_admin&.email})"
|
||||
)
|
||||
end
|
||||
|
||||
def resolve_user(query)
|
||||
return if query.blank?
|
||||
|
||||
query.match?(/\A\d+\z/) ? User.find_by(id: query) : User.from_email(query)
|
||||
end
|
||||
|
||||
def parsed_subscription_ids
|
||||
Array(params[:subscription_ids]).reject(&:blank?).map(&:to_i)
|
||||
end
|
||||
end
|
||||
@@ -72,6 +72,27 @@ class ArticlesAPI extends PortalsAPI {
|
||||
category_slug: categorySlug,
|
||||
});
|
||||
}
|
||||
|
||||
bulkTranslate({ portalSlug, articleIds, locale, categoryId, force = false }) {
|
||||
return axios.post(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/translate`,
|
||||
{ ids: articleIds, locale, category_id: categoryId, force }
|
||||
);
|
||||
}
|
||||
|
||||
bulkUpdateStatus({ portalSlug, articleIds, status }) {
|
||||
return axios.patch(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/update_status`,
|
||||
{ ids: articleIds, status }
|
||||
);
|
||||
}
|
||||
|
||||
bulkDelete({ portalSlug, articleIds }) {
|
||||
return axios.delete(
|
||||
`${this.url}/${portalSlug}/articles/bulk_actions/delete_articles`,
|
||||
{ data: { ids: articleIds } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ArticlesAPI();
|
||||
|
||||
@@ -107,11 +107,10 @@ const closeMobileSidebar = () => {
|
||||
size="sm"
|
||||
/>
|
||||
<ComposeConversation :contact-id="contactId">
|
||||
<template #trigger="{ toggle }">
|
||||
<template #trigger>
|
||||
<Button
|
||||
:label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
|
||||
size="sm"
|
||||
@click="toggle"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -114,8 +114,8 @@ const emit = defineEmits([
|
||||
</div>
|
||||
<div class="w-px h-4 bg-n-strong" />
|
||||
<ComposeConversation>
|
||||
<template #trigger="{ toggle }">
|
||||
<Button :label="buttonLabel" size="sm" @click="toggle" />
|
||||
<template #trigger>
|
||||
<Button :label="buttonLabel" size="sm" />
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
contact: { type: Object, required: true },
|
||||
selected: { type: Boolean, default: false },
|
||||
enableSelection: { type: Boolean, default: true },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['selectConversation']);
|
||||
|
||||
const hovered = ref(false);
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
emit('selectConversation', value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-center flex-shrink-0"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="contact.name"
|
||||
:src="contact.thumbnail"
|
||||
:size="24"
|
||||
:status="contact.availability_status"
|
||||
hide-offline-status
|
||||
>
|
||||
<template v-if="enableSelection" #overlay>
|
||||
<div
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-md cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] size-6"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</template>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import UnreadBadge from './UnreadBadge.vue';
|
||||
|
||||
defineProps({
|
||||
lastMessage: { type: Object, default: null },
|
||||
voiceCallStatus: { type: String, default: '' },
|
||||
voiceCallDirection: { type: String, default: '' },
|
||||
unreadCount: { type: Number, default: 0 },
|
||||
showExpandedPreview: { type: Boolean, default: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-[1fr_auto] gap-1.5"
|
||||
:class="showExpandedPreview ? 'items-end' : 'items-center'"
|
||||
>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallStatus"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallStatus"
|
||||
:direction="voiceCallDirection"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessage"
|
||||
key="message-preview"
|
||||
:message="lastMessage"
|
||||
:multi-line="showExpandedPreview"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1 text-body-main"
|
||||
:class="unreadCount > 0 ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
>
|
||||
<Icon icon="i-lucide-info" class="size-3.5" />
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
|
||||
<UnreadBadge :count="unreadCount" :align-bottom="showExpandedPreview" />
|
||||
</div>
|
||||
</template>
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref, computed, nextTick, useSlots, watch, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useThrottleFn } from '@vueuse/core';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
labels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
disableToggle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.labels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
const showAllLabels = ref(false);
|
||||
const showExpandLabelButton = ref(false);
|
||||
const labelPosition = ref(-1);
|
||||
const labelContainer = ref(null);
|
||||
|
||||
// Show if there are labels OR if before slot exists
|
||||
const showSection = computed(
|
||||
() => activeLabels.value.length > 0 || !!slots.before
|
||||
);
|
||||
|
||||
const computeVisibleLabelPosition = () => {
|
||||
const container = labelContainer.value;
|
||||
if (!container || activeLabels.value.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = container.querySelectorAll('[data-label]');
|
||||
if (labels.length === 0) {
|
||||
showExpandLabelButton.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if all labels are visible
|
||||
if (showAllLabels.value) return;
|
||||
|
||||
const beforeSlot = container.querySelector('[data-before-slot]');
|
||||
const beforeSlotWidth = beforeSlot?.offsetWidth ?? 0;
|
||||
const availableWidth = container.clientWidth - 46 - beforeSlotWidth;
|
||||
|
||||
let totalWidth = 0;
|
||||
const labelsArray = Array.from(labels);
|
||||
|
||||
// Find last visible label index using some() - stops early on overflow
|
||||
const overflowIndex = labelsArray.findIndex(label => {
|
||||
totalWidth += label.offsetWidth + 6;
|
||||
return totalWidth > availableWidth;
|
||||
});
|
||||
|
||||
const visibleIndex =
|
||||
overflowIndex === -1 ? labelsArray.length - 1 : overflowIndex - 1;
|
||||
|
||||
labelPosition.value = visibleIndex;
|
||||
showExpandLabelButton.value = visibleIndex < labelsArray.length - 1;
|
||||
};
|
||||
|
||||
const throttledCalculate = useThrottleFn(computeVisibleLabelPosition, 16);
|
||||
|
||||
watch(activeLabels, () => nextTick(throttledCalculate), { immediate: true });
|
||||
|
||||
const hiddenLabelsCount = computed(() => {
|
||||
if (!showExpandLabelButton.value || showAllLabels.value) return 0;
|
||||
return activeLabels.value.length - labelPosition.value - 1;
|
||||
});
|
||||
|
||||
// Check if all labels are hidden (none visible)
|
||||
const allLabelsHidden = computed(() => {
|
||||
return labelPosition.value === -1 && activeLabels.value.length > 0;
|
||||
});
|
||||
|
||||
// Label text for button when disableToggle is true and all labels are hidden
|
||||
const labelsCountText = computed(() => {
|
||||
if (props.disableToggle && allLabelsHidden.value) {
|
||||
return t('CONVERSATION.CARD.LABELS_COUNT', {
|
||||
count: activeLabels.value.length,
|
||||
});
|
||||
}
|
||||
if (!showAllLabels.value && hiddenLabelsCount.value > 0) {
|
||||
return hiddenLabelsCount.value;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const hiddenLabelsTooltip = computed(() => {
|
||||
if (!props.disableToggle) return '';
|
||||
// When all labels are hidden, show all label titles
|
||||
if (allLabelsHidden.value) {
|
||||
return activeLabels.value.map(label => label.title).join(', ');
|
||||
}
|
||||
if (!showExpandLabelButton.value) return '';
|
||||
const hiddenLabels = activeLabels.value.slice(labelPosition.value + 1);
|
||||
return hiddenLabels.map(label => label.title).join(', ');
|
||||
});
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
if (props.disableToggle && hiddenLabelsTooltip.value) {
|
||||
return hiddenLabelsTooltip.value;
|
||||
}
|
||||
return showAllLabels.value
|
||||
? t('CONVERSATION.CARD.HIDE_LABELS')
|
||||
: t('CONVERSATION.CARD.SHOW_LABELS');
|
||||
});
|
||||
|
||||
const onShowLabels = e => {
|
||||
e.stopPropagation();
|
||||
if (props.disableToggle) return;
|
||||
showAllLabels.value = !showAllLabels.value;
|
||||
nextTick(() => computeVisibleLabelPosition());
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showSection"
|
||||
ref="labelContainer"
|
||||
v-bind="attrs"
|
||||
v-resize="throttledCalculate"
|
||||
data-labels-container
|
||||
class="flex items-center flex-shrink min-w-0 min-h-6 gap-x-1.5 gap-y-1 [&:not(:has([data-label],[data-before-slot]))]:hidden"
|
||||
:class="{ 'h-auto overflow-visible flex-row flex-wrap': showAllLabels }"
|
||||
>
|
||||
<slot name="before" />
|
||||
|
||||
<Label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label ? label.id : index"
|
||||
data-label
|
||||
:label="label"
|
||||
compact
|
||||
:class="{
|
||||
'invisible absolute': !showAllLabels && index > labelPosition,
|
||||
}"
|
||||
/>
|
||||
<Button
|
||||
v-if="showExpandLabelButton || (disableToggle && allLabelsHidden)"
|
||||
v-tooltip.top="{
|
||||
content: tooltipText,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:label="labelsCountText"
|
||||
xs
|
||||
slate
|
||||
:no-animation="disableToggle"
|
||||
:icon="labelsCountText ? 'i-lucide-plus' : 'i-lucide-chevron-left'"
|
||||
class="!py-0 !px-1.5 flex-shrink-0 !rounded-md !bg-n-button-color -outline-offset-1 !gap-0.5 [&>span:first-child]:!text-n-slate-10 [&>span:last-child]:!text-n-slate-11"
|
||||
:class="{ 'cursor-default': disableToggle }"
|
||||
@click="onShowLabels"
|
||||
/>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { CONVERSATION_STATUS } from 'shared/constants/messages';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const icons = {
|
||||
[CONVERSATION_STATUS.OPEN]: 'i-woot-status-open',
|
||||
[CONVERSATION_STATUS.RESOLVED]: 'i-woot-status-resolved',
|
||||
[CONVERSATION_STATUS.PENDING]: 'i-woot-status-pending',
|
||||
[CONVERSATION_STATUS.SNOOZED]: 'i-woot-status-snoozed',
|
||||
};
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.status && icons[props.status]) {
|
||||
return icons[props.status];
|
||||
}
|
||||
return props.showEmpty ? 'i-woot-status-empty' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Icon
|
||||
v-tooltip.top="{
|
||||
content: status,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:icon="iconName"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
<script setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import CardAvatar from './CardAvatar.vue';
|
||||
import CardContent from './CardContent.vue';
|
||||
import CardLabels from './CardLabelsV5.vue';
|
||||
import CardPriorityIcon from './CardPriorityIcon.vue';
|
||||
import InboxName from 'dashboard/components-next/Conversation/InboxName.vue';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import SLACardLabel from 'dashboard/components-next/Conversation/Sla/SLACardLabel.vue';
|
||||
import CardStatusIcon from './CardStatusIcon.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
isInboxView: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
]);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||
|
||||
const hasSlaPolicyId = computed(
|
||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
||||
);
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => {
|
||||
if (value) {
|
||||
emit('selectConversation', value);
|
||||
} else {
|
||||
emit('deSelectConversation', value);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="conversation relative cursor-pointer group grid gap-4 items-center px-3 h-12 border-b border-n-slate-3 hover:border-n-surface-1 hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-alpha-1 dark:bg-n-alpha-3 !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 dark:bg-n-slate-3 !border-n-surface-1': selected,
|
||||
'hover:bg-n-alpha-1': !isActiveChat && !selected,
|
||||
'grid-cols-[minmax(0,2fr)_minmax(0,1fr)]': showLabelsSection,
|
||||
'grid-cols-[minmax(0,2fr)_max-content]': !showLabelsSection,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<!-- LEFT SECTION -->
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center flex-shrink-0" @click.stop>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardPriorityIcon :priority="chat.priority" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<Avatar
|
||||
v-if="showAssignee && assignee.name"
|
||||
v-tooltip.top="{
|
||||
content: assignee.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
:name="assignee.name"
|
||||
:src="assignee.thumbnail"
|
||||
:size="14"
|
||||
:status="assignee.availability_status"
|
||||
hide-offline-status
|
||||
/>
|
||||
<Icon
|
||||
v-else
|
||||
icon="i-woot-empty-assignee"
|
||||
class="size-4 text-n-slate-7"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-4 flex items-center justify-center flex-shrink-0">
|
||||
<CardStatusIcon :status="chat.status" show-empty />
|
||||
</div>
|
||||
|
||||
<div class="w-px h-3 bg-n-slate-6 flex-shrink-0" />
|
||||
|
||||
<div v-if="!isInboxView && showInboxName" class="w-20 flex-shrink-0">
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="min-w-0" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isInboxView && showInboxName"
|
||||
class="w-px h-3 bg-n-slate-6 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-tooltip.top="{
|
||||
content: chat.id,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="h-6 flex items-center gap-1 max-w-20 w-full min-w-0 flex-shrink-0"
|
||||
>
|
||||
<Icon
|
||||
icon="i-woot-hash"
|
||||
class="size-3.5 text-n-slate-10 flex-shrink-0"
|
||||
/>
|
||||
<span class="text-body-main text-n-slate-11 truncate">
|
||||
{{ chat.id }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CardAvatar
|
||||
:contact="currentContact"
|
||||
:selected="false"
|
||||
:enable-selection="false"
|
||||
:hide-thumbnail="false"
|
||||
/>
|
||||
|
||||
<h4
|
||||
class="text-heading-3 my-0 capitalize truncate text-n-slate-12 font-medium w-32 flex-shrink-0"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
|
||||
<CardContent
|
||||
:last-message="lastMessageInChat"
|
||||
:voice-call-status="voiceCallData.status"
|
||||
:voice-call-direction="voiceCallData.direction"
|
||||
:unread-count="unreadCount"
|
||||
:show-expanded-preview="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT SECTION -->
|
||||
<div class="flex items-center justify-end gap-1.5 flex-shrink-0">
|
||||
<div v-if="showLabelsSection" class="min-w-0 w-full">
|
||||
<CardLabels
|
||||
:labels="chat.labels"
|
||||
disable-toggle
|
||||
class="my-0 [&>div]:justify-end justify-end"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSlaPolicyId" class="flex-shrink-0">
|
||||
<SLACardLabel ref="slaCardLabel" :chat="chat" />
|
||||
</div>
|
||||
|
||||
<div class="flex-shrink-0 w-[4.375rem] text-end">
|
||||
<TimeAgo
|
||||
:conversation-id="chat.id"
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
class="font-440 !text-xs text-n-slate-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showMessageType: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
defaultEmptyMessage: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
multiLine: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
|
||||
const attachmentIcons = {
|
||||
image: 'i-lucide-image',
|
||||
audio: 'i-lucide-headphones',
|
||||
video: 'i-lucide-video',
|
||||
file: 'i-lucide-file',
|
||||
location: 'i-lucide-map-pin',
|
||||
fallback: 'i-lucide-link-2',
|
||||
};
|
||||
|
||||
const messageByAgent = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.OUTGOING;
|
||||
});
|
||||
|
||||
const isMessageAnActivity = computed(() => {
|
||||
const { message_type: messageType } = props.message;
|
||||
return messageType === MESSAGE_TYPE.ACTIVITY;
|
||||
});
|
||||
|
||||
const isMessagePrivate = computed(() => {
|
||||
const { private: isPrivate } = props.message;
|
||||
return isPrivate;
|
||||
});
|
||||
|
||||
const parsedLastMessage = computed(() => {
|
||||
const { content_attributes: contentAttributes } = props.message;
|
||||
const { email: { subject } = {} } = contentAttributes || {};
|
||||
return getPlainText(subject || props.message.content);
|
||||
});
|
||||
|
||||
const lastMessageFileType = computed(() => {
|
||||
const [{ file_type: fileType } = {}] = props.message.attachments;
|
||||
return fileType;
|
||||
});
|
||||
|
||||
const attachmentIcon = computed(() => {
|
||||
return attachmentIcons[lastMessageFileType.value];
|
||||
});
|
||||
|
||||
const attachmentMessageContent = computed(() => {
|
||||
return `CHAT_LIST.ATTACHMENTS.${lastMessageFileType.value}.CONTENT`;
|
||||
});
|
||||
|
||||
const isMessageSticker = computed(() => {
|
||||
return props.message && props.message.content_type === 'sticker';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-w-0 text-sm"
|
||||
:class="
|
||||
multiLine
|
||||
? 'flex items-start gap-1'
|
||||
: 'grid grid-cols-[auto_1fr] items-center gap-1'
|
||||
"
|
||||
>
|
||||
<template v-if="showMessageType && !multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="size-3.5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span
|
||||
class="min-w-0 text-body-main"
|
||||
:class="multiLine ? 'line-clamp-2' : 'truncate'"
|
||||
>
|
||||
<!-- Case for previous and conversation conversation card -->
|
||||
<template v-if="showMessageType && multiLine">
|
||||
<Icon
|
||||
v-if="isMessagePrivate"
|
||||
icon="i-lucide-lock-keyhole"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="messageByAgent"
|
||||
icon="i-lucide-undo-2"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="isMessageAnActivity"
|
||||
icon="i-lucide-info"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="message.content && isMessageSticker"
|
||||
class="inline-grid grid-flow-col auto-cols-max items-center gap-1"
|
||||
>
|
||||
<Icon icon="i-lucide-image" class="size-3.5" />
|
||||
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
|
||||
</span>
|
||||
|
||||
<template v-else-if="message.content">
|
||||
{{ parsedLastMessage }}
|
||||
</template>
|
||||
|
||||
<span
|
||||
v-else-if="message.attachments"
|
||||
class="inline-block align-middle truncate"
|
||||
>
|
||||
<Icon
|
||||
v-if="attachmentIcon && showMessageType"
|
||||
:icon="attachmentIcon"
|
||||
class="inline-block align-middle size-3.5 ltr:mr-1 rtl:ml-1"
|
||||
/>
|
||||
<span class="inline-block align-middle">
|
||||
{{ $t(attachmentMessageContent) }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<template v-else>
|
||||
{{ defaultEmptyMessage || $t('CHAT_LIST.NO_CONTENT') }}
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const props = defineProps({
|
||||
count: { type: Number, required: true },
|
||||
alignBottom: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const displayCount = computed(() =>
|
||||
props.count > 9 ? t('CHAT_LIST.UNREAD_COUNT_OVERFLOW') : props.count
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="count > 0"
|
||||
class="bg-n-teal-9 rounded-full h-4 min-w-4 max-w-5 px-1 w-fit font-medium text-xxs leading-3 text-white inline-grid place-items-center flex-shrink-0"
|
||||
:class="{
|
||||
'mb-0.5': alignBottom,
|
||||
}"
|
||||
>
|
||||
{{ displayCount }}
|
||||
</span>
|
||||
<span v-else />
|
||||
</template>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-[auto_1fr] items-center gap-1 min-w-0 text-sm">
|
||||
<Icon class="size-3.5" :icon="iconName" :class="statusColor" />
|
||||
<span class="truncate text-body-main" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
|
||||
defineProps({
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :title="inbox.name" class="flex items-center gap-0.5 min-w-0">
|
||||
<ChannelIcon :inbox="inbox" class="size-4 flex-shrink-0 text-n-slate-11" />
|
||||
<span class="truncate text-body-main text-n-slate-11">
|
||||
{{ inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Label from 'dashboard/components-next/label/Label.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const REFRESH_INTERVAL = 60000;
|
||||
|
||||
const timer = ref(null);
|
||||
const slaStatus = ref({
|
||||
threshold: null,
|
||||
isSlaMissed: false,
|
||||
type: null,
|
||||
icon: null,
|
||||
});
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||
|
||||
const updateSlaStatus = () => {
|
||||
slaStatus.value = evaluateSLAStatus({
|
||||
appliedSla: appliedSLA.value || {},
|
||||
chat: props.chat,
|
||||
});
|
||||
};
|
||||
|
||||
const createTimer = () => {
|
||||
timer.value = setTimeout(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
}, REFRESH_INTERVAL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateSlaStatus();
|
||||
createTimer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.chat, updateSlaStatus);
|
||||
|
||||
defineExpose({
|
||||
hasSlaThreshold,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="hasSlaThreshold"
|
||||
v-bind="$attrs"
|
||||
class="relative flex items-center cursor-pointer min-w-fit group"
|
||||
>
|
||||
<Label
|
||||
:label="slaStatus.threshold"
|
||||
:color="isSlaMissed ? 'ruby' : 'amber'"
|
||||
compact
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="i-lucide-flame" class="flex-shrink-0 size-3.5" />
|
||||
</template>
|
||||
</Label>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -9,11 +9,15 @@ import {
|
||||
ARTICLE_STATUSES,
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -44,14 +48,46 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showSelectionControl: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['openArticle', 'articleAction']);
|
||||
const emit = defineEmits([
|
||||
'openArticle',
|
||||
'articleAction',
|
||||
'toggleSelect',
|
||||
'hover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const articleMenuItems = computed(() => {
|
||||
const commonItems = Object.entries(ARTICLE_MENU_ITEMS).reduce(
|
||||
(acc, [key, item]) => {
|
||||
@@ -64,7 +100,9 @@ const articleMenuItems = computed(() => {
|
||||
const statusItems = (
|
||||
ARTICLE_MENU_OPTIONS[props.status] ||
|
||||
ARTICLE_MENU_OPTIONS[ARTICLE_STATUSES.PUBLISHED]
|
||||
).map(key => commonItems[key]);
|
||||
)
|
||||
.filter(key => key !== 'translate' || isTranslationAvailable.value)
|
||||
.map(key => commonItems[key]);
|
||||
|
||||
return [...statusItems, commonItems.delete];
|
||||
});
|
||||
@@ -123,14 +161,27 @@ const handleClick = id => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout>
|
||||
<CardLayout
|
||||
:selectable="selectable"
|
||||
class="relative"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div
|
||||
v-show="showSelectionControl"
|
||||
class="absolute top-7 ltr:left-3 rtl:right-3"
|
||||
>
|
||||
<Checkbox :model-value="isSelected" @change="emit('toggleSelect', id)" />
|
||||
</div>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="text-base cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12 line-clamp-1"
|
||||
@click="handleClick(id)"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
|
||||
|
||||
+2
-2
@@ -121,7 +121,7 @@ const handleCreateArticle = event => {
|
||||
custom-text-area-class="!text-[32px] !leading-[48px] !font-medium !tracking-[0.2px]"
|
||||
custom-text-area-wrapper-class="border-0 !bg-transparent dark:!bg-transparent !py-0 !px-0"
|
||||
placeholder="Title"
|
||||
autofocus
|
||||
:autofocus="isNewArticle"
|
||||
@blur="handleCreateArticle"
|
||||
/>
|
||||
<ArticleEditorControls
|
||||
@@ -138,7 +138,7 @@ const handleCreateArticle = event => {
|
||||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.EDITOR_PLACEHOLDER')
|
||||
"
|
||||
:enabled-menu-options="ARTICLE_EDITOR_MENU_OPTIONS"
|
||||
:autofocus="false"
|
||||
:autofocus="!isNewArticle"
|
||||
/>
|
||||
</template>
|
||||
</HelpCenterLayout>
|
||||
|
||||
+31
-2
@@ -20,8 +20,14 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
selectedArticleIds: {
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
||||
|
||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||
|
||||
const router = useRouter();
|
||||
@@ -30,12 +36,26 @@ const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const localArticles = ref(props.articles);
|
||||
const hoveredArticleId = ref(null);
|
||||
|
||||
const dragEnabled = computed(() => {
|
||||
// Enable dragging only for category articles and when there's more than one article
|
||||
return props.isCategoryArticles && localArticles.value?.length > 1;
|
||||
return (
|
||||
props.isCategoryArticles &&
|
||||
localArticles.value?.length > 1 &&
|
||||
props.selectedArticleIds.size === 0
|
||||
);
|
||||
});
|
||||
|
||||
const hasBulkSelection = computed(() => props.selectedArticleIds.size > 0);
|
||||
|
||||
const shouldShowSelectionControl = id => {
|
||||
return hoveredArticleId.value === id || hasBulkSelection.value;
|
||||
};
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredArticleId.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const getCategoryById = useMapGetter('categories/categoryById');
|
||||
|
||||
const openArticle = id => {
|
||||
@@ -152,6 +172,10 @@ const handleArticleAction = async (action, { status, id }) => {
|
||||
};
|
||||
|
||||
const updateArticle = ({ action, value, id }) => {
|
||||
if (action === 'translate') {
|
||||
emit('translateArticle', id);
|
||||
return;
|
||||
}
|
||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||
handleArticleAction(action, { status, id });
|
||||
};
|
||||
@@ -187,9 +211,14 @@ watch(
|
||||
:category="getCategory(element.category.id)"
|
||||
:views="element.views || 0"
|
||||
:updated-at="element.updatedAt"
|
||||
:is-selected="selectedArticleIds.has(element.id)"
|
||||
selectable
|
||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
||||
:class="{ 'cursor-grab': dragEnabled }"
|
||||
@open-article="openArticle"
|
||||
@article-action="updateArticle"
|
||||
@toggle-select="emit('toggleSelect', $event)"
|
||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
||||
/>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
+231
-7
@@ -1,9 +1,13 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import articlesAPI from 'dashboard/api/helpCenter/articles';
|
||||
|
||||
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
|
||||
import ArticleList from 'dashboard/components-next/HelpCenter/Pages/ArticlePage/ArticleList.vue';
|
||||
@@ -11,6 +15,10 @@ import ArticleHeaderControls from 'dashboard/components-next/HelpCenter/Pages/Ar
|
||||
import CategoryHeaderControls from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryHeaderControls.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import BulkTranslateDialog from './BulkTranslateDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
articles: {
|
||||
@@ -39,7 +47,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal']);
|
||||
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -47,6 +55,42 @@ const { t } = useI18n();
|
||||
|
||||
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
|
||||
const isFetching = useMapGetter('articles/isFetching');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledonAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const selectedArticleIds = ref(new Set());
|
||||
const deleteConfirmDialogRef = ref(null);
|
||||
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const isTranslationAvailable = computed(
|
||||
() =>
|
||||
isEnterprise &&
|
||||
isFeatureEnabledonAccount.value(
|
||||
currentAccountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_TASKS
|
||||
)
|
||||
);
|
||||
|
||||
const allItems = computed(() => props.articles.map(a => ({ id: a.id })));
|
||||
const visibleArticleIds = computed(() => props.articles.map(a => a.id));
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!visibleArticleIds.value.length) return '';
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECT_ALL', {
|
||||
count: visibleArticleIds.value.length,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.SELECTED_COUNT', {
|
||||
count: selectedArticleIds.value.size,
|
||||
})
|
||||
);
|
||||
|
||||
const bulkTranslateDialogRef = ref(null);
|
||||
|
||||
const hasNoArticles = computed(
|
||||
() => !isFetching.value && !props.articles.length
|
||||
@@ -128,6 +172,80 @@ const navigateToNewArticlePage = () => {
|
||||
params: { categorySlug, locale },
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleSelect = articleId => {
|
||||
const newSet = new Set(selectedArticleIds.value);
|
||||
if (newSet.has(articleId)) {
|
||||
newSet.delete(articleId);
|
||||
} else {
|
||||
newSet.add(articleId);
|
||||
}
|
||||
selectedArticleIds.value = newSet;
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedArticleIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleTranslateArticle = articleId => {
|
||||
selectedArticleIds.value = new Set([articleId]);
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const openTranslateDialog = () => {
|
||||
bulkTranslateDialogRef.value?.dialogRef?.open();
|
||||
};
|
||||
|
||||
const onBulkActionSuccess = message => {
|
||||
useAlert(message);
|
||||
clearSelection();
|
||||
emit('refreshArticles');
|
||||
};
|
||||
|
||||
const bulkUpdateStatus = async status => {
|
||||
try {
|
||||
await articlesAPI.bulkUpdateStatus({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
status,
|
||||
});
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.STATUS_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
deleteConfirmDialogRef.value?.open();
|
||||
};
|
||||
|
||||
const bulkDelete = async () => {
|
||||
try {
|
||||
await articlesAPI.bulkDelete({
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: [...selectedArticleIds.value],
|
||||
});
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
onBulkActionSuccess(
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_SUCCESS')
|
||||
);
|
||||
} catch (error) {
|
||||
deleteConfirmDialogRef.value?.close();
|
||||
useAlert(
|
||||
error?.message || t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_ERROR')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear selection when articles change (page change, filter change)
|
||||
watch(
|
||||
() => props.articles,
|
||||
() => clearSelection()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,11 +284,91 @@ const navigateToNewArticlePage = () => {
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<ArticleList
|
||||
v-else-if="!hasNoArticles"
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
/>
|
||||
<template v-else-if="!hasNoArticles">
|
||||
<div
|
||||
v-if="selectedArticleIds.size > 0"
|
||||
class="sticky top-0 z-[5] bg-gradient-to-b from-n-surface-1 from-90% to-transparent pt-1 pb-2"
|
||||
>
|
||||
<BulkSelectBar
|
||||
v-model="selectedArticleIds"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3 justify-between"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CLEAR_SELECTION')
|
||||
"
|
||||
class="!px-1.5"
|
||||
@click="clearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-check"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.PUBLISH')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('published')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-pencil-line"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DRAFT')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('draft')"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-archive-restore"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.ARCHIVE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="bulkUpdateStatus('archived')"
|
||||
/>
|
||||
<Button
|
||||
v-if="isTranslationAvailable"
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-languages"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.TRANSLATE')"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="openTranslateDialog"
|
||||
/>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
ruby
|
||||
icon="i-lucide-trash"
|
||||
:label="t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE')"
|
||||
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||
@click="confirmBulkDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</div>
|
||||
<ArticleList
|
||||
:articles="articles"
|
||||
:is-category-articles="isCategoryArticles"
|
||||
:selected-article-ids="selectedArticleIds"
|
||||
class="relative z-0"
|
||||
@translate-article="handleTranslateArticle"
|
||||
@toggle-select="handleToggleSelect"
|
||||
/>
|
||||
</template>
|
||||
<ArticleEmptyState
|
||||
v-else
|
||||
class="pt-14"
|
||||
@@ -183,5 +381,31 @@ const navigateToNewArticlePage = () => {
|
||||
@click="navigateToNewArticlePage"
|
||||
/>
|
||||
</template>
|
||||
<BulkTranslateDialog
|
||||
ref="bulkTranslateDialogRef"
|
||||
:selected-article-ids="[...selectedArticleIds]"
|
||||
:allowed-locales="allowedLocales"
|
||||
@translate-started="clearSelection"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConfirmDialogRef"
|
||||
type="alert"
|
||||
:title="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_TITLE',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:description="
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM_DESCRIPTION',
|
||||
selectedArticleIds.size
|
||||
)
|
||||
"
|
||||
:confirm-button-label="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_ACTIONS.DELETE_CONFIRM')
|
||||
"
|
||||
@confirm="bulkDelete"
|
||||
/>
|
||||
</HelpCenterLayout>
|
||||
</template>
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedArticleIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
allowedLocales: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['translateStarted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSubmitting = ref(false);
|
||||
const selectedLocale = ref('');
|
||||
const selectedCategoryId = ref('');
|
||||
const targetCategories = ref([]);
|
||||
const isFetchingCategories = ref(false);
|
||||
const duplicateArticles = ref([]);
|
||||
|
||||
const currentLocale = computed(() => route.params.locale);
|
||||
|
||||
const localeOptions = computed(() => {
|
||||
return props.allowedLocales
|
||||
.filter(locale => locale.code !== currentLocale.value)
|
||||
.map(locale => ({
|
||||
value: locale.code,
|
||||
label: `${locale.name} (${locale.code})`,
|
||||
}));
|
||||
});
|
||||
|
||||
const categoryOptions = computed(() => {
|
||||
return targetCategories.value.map(category => ({
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
}));
|
||||
});
|
||||
|
||||
const articleCount = computed(() => props.selectedArticleIds.length);
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.TITLE', articleCount.value)
|
||||
);
|
||||
|
||||
const description = computed(() =>
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DESCRIPTION', articleCount.value)
|
||||
);
|
||||
|
||||
const hasDuplicates = computed(() => duplicateArticles.value.length > 0);
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
if (hasDuplicates.value) {
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM_OVERWRITE');
|
||||
}
|
||||
return t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CONFIRM');
|
||||
});
|
||||
|
||||
const isConfirmDisabled = computed(() => {
|
||||
return !selectedLocale.value || isSubmitting.value;
|
||||
});
|
||||
|
||||
const articleEditUrl = articleId => {
|
||||
const { portalSlug, categorySlug, tab } = route.params;
|
||||
const resolved = router.resolve({
|
||||
name: 'portals_articles_edit',
|
||||
params: {
|
||||
portalSlug,
|
||||
locale: selectedLocale.value,
|
||||
categorySlug,
|
||||
tab,
|
||||
articleSlug: articleId,
|
||||
},
|
||||
});
|
||||
return resolved.href;
|
||||
};
|
||||
|
||||
const fetchCategoriesForLocale = async locale => {
|
||||
if (!locale) {
|
||||
targetCategories.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingCategories.value = true;
|
||||
try {
|
||||
const { data } = await categoriesAPI.get({
|
||||
portalSlug: route.params.portalSlug,
|
||||
locale,
|
||||
});
|
||||
targetCategories.value = data.payload;
|
||||
} catch {
|
||||
targetCategories.value = [];
|
||||
} finally {
|
||||
isFetchingCategories.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedLocale, newLocale => {
|
||||
selectedCategoryId.value = '';
|
||||
duplicateArticles.value = [];
|
||||
fetchCategoriesForLocale(newLocale);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
selectedCategoryId.value = '';
|
||||
targetCategories.value = [];
|
||||
duplicateArticles.value = [];
|
||||
};
|
||||
|
||||
const submitTranslation = async (force = false) => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await store.dispatch('articles/bulkTranslate', {
|
||||
portalSlug: route.params.portalSlug,
|
||||
articleIds: props.selectedArticleIds,
|
||||
locale: selectedLocale.value,
|
||||
categoryId: selectedCategoryId.value,
|
||||
force,
|
||||
});
|
||||
|
||||
useAlert(t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.SUCCESS_MESSAGE'));
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
emit('translateStarted');
|
||||
} catch (error) {
|
||||
if (error.response?.status === 409) {
|
||||
duplicateArticles.value = error.response.data.duplicate_articles;
|
||||
return;
|
||||
}
|
||||
useAlert(
|
||||
error?.message ||
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.API.ERROR_MESSAGE')
|
||||
);
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
if (isConfirmDisabled.value) return;
|
||||
submitTranslation(hasDuplicates.value);
|
||||
};
|
||||
|
||||
defineExpose({ dialogRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
:title="dialogTitle"
|
||||
:description="description"
|
||||
:confirm-button-label="confirmLabel"
|
||||
:disable-confirm-button="isConfirmDisabled"
|
||||
:is-loading="isSubmitting"
|
||||
@close="resetForm"
|
||||
@confirm="onConfirm"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedLocale"
|
||||
:options="localeOptions"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.LOCALE_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_LABEL') }}
|
||||
<span class="text-n-slate-10 font-normal">
|
||||
{{ t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.OPTIONAL') }}
|
||||
</span>
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="selectedCategoryId"
|
||||
:options="categoryOptions"
|
||||
:disabled="!selectedLocale || isFetchingCategories"
|
||||
:placeholder="
|
||||
t('HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.CATEGORY_PLACEHOLDER')
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="hasDuplicates"
|
||||
class="flex gap-3 p-3 rounded-xl bg-n-amber-2 border border-n-amber-5"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-triangle-alert"
|
||||
class="size-4 mt-0.5 text-n-amber-11 shrink-0"
|
||||
/>
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<p class="text-sm text-n-amber-12 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_WARNING',
|
||||
duplicateArticles.length
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a
|
||||
v-for="article in duplicateArticles"
|
||||
:key="article.id"
|
||||
:href="articleEditUrl(article.id)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-amber-12 underline underline-offset-2 hover:text-n-amber-11 truncate"
|
||||
>
|
||||
{{ article.title }}
|
||||
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-xs text-n-amber-11 m-0">
|
||||
{{
|
||||
t(
|
||||
'HELP_CENTER.ARTICLES_PAGE.BULK_TRANSLATE.DUPLICATE_CONFIRM_HINT'
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -2,13 +2,10 @@
|
||||
import { reactive, ref, computed, onMounted, watch } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWindowSize } from '@vueuse/core';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@@ -18,22 +15,18 @@ import {
|
||||
processContactableInboxes,
|
||||
mergeInboxDetails,
|
||||
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
alignPosition: {
|
||||
type: String,
|
||||
default: 'left',
|
||||
},
|
||||
contactId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
isModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,23 +35,16 @@ const emit = defineEmits(['close']);
|
||||
const searchContacts = createContactSearcher();
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
const { fetchSignatureFlagFromUISettings } = useUISettings();
|
||||
|
||||
const isSmallScreen = computed(
|
||||
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
|
||||
);
|
||||
|
||||
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
|
||||
|
||||
const popoverRef = ref(null);
|
||||
const contacts = ref([]);
|
||||
const selectedContact = ref(null);
|
||||
const targetInbox = ref(null);
|
||||
const isCreatingContact = ref(false);
|
||||
const isFetchingInboxes = ref(false);
|
||||
const isSearching = ref(false);
|
||||
const showComposeNewConversation = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
message: '',
|
||||
@@ -95,14 +81,6 @@ const directUploadsEnabled = computed(
|
||||
|
||||
const activeContact = computed(() => contactById.value(props.contactId));
|
||||
|
||||
const composePopoverClass = computed(() => {
|
||||
if (viewInModal.value) return '';
|
||||
|
||||
return props.alignPosition === 'right'
|
||||
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
|
||||
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
|
||||
});
|
||||
|
||||
const onContactSearch = debounce(
|
||||
async query => {
|
||||
isSearching.value = true;
|
||||
@@ -172,7 +150,7 @@ const clearSelectedContact = () => {
|
||||
};
|
||||
|
||||
const closeCompose = () => {
|
||||
showComposeNewConversation.value = false;
|
||||
popoverRef.value?.hide();
|
||||
if (!props.contactId) {
|
||||
// If contactId is passed as prop
|
||||
// Then don't allow to remove the selected contact
|
||||
@@ -180,7 +158,6 @@ const closeCompose = () => {
|
||||
}
|
||||
targetInbox.value = null;
|
||||
resetContacts();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const discardCompose = () => {
|
||||
@@ -213,8 +190,15 @@ const createConversation = async ({ payload, isFromWhatsApp }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
showComposeNewConversation.value = !showComposeNewConversation.value;
|
||||
const onPopoverShow = () => {
|
||||
// Flag to prevent triggering drag n drop,
|
||||
// When compose modal is active
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onPopoverHide = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -242,64 +226,23 @@ watch(
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const handleClickOutside = () => {
|
||||
if (!showComposeNewConversation.value) return;
|
||||
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onModalBackdropClick = () => {
|
||||
if (!viewInModal.value) return;
|
||||
handleClickOutside();
|
||||
};
|
||||
|
||||
onMounted(() => resetContacts());
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (showComposeNewConversation.value) {
|
||||
showComposeNewConversation.value = false;
|
||||
emit('close');
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
// Fixed and edge case https://github.com/chatwoot/chatwoot/issues/10785
|
||||
// This will prevent closing the compose conversation modal when the editor Create link popup is open
|
||||
{ ignore: ['dialog.ProseMirror-prompt-backdrop'] },
|
||||
]"
|
||||
class="relative"
|
||||
:class="{
|
||||
'z-50': showComposeNewConversation && !viewInModal,
|
||||
}"
|
||||
<Popover
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
<slot
|
||||
name="trigger"
|
||||
:is-open="showComposeNewConversation"
|
||||
:toggle="toggle"
|
||||
/>
|
||||
<div
|
||||
v-if="showComposeNewConversation"
|
||||
:class="{
|
||||
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
|
||||
viewInModal,
|
||||
}"
|
||||
@click.self="onModalBackdropClick"
|
||||
>
|
||||
<template #default="{ isOpen }">
|
||||
<slot name="trigger" :is-open="isOpen" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ComposeNewConversationForm
|
||||
:form-state="formState"
|
||||
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
|
||||
:contacts="contacts"
|
||||
:contact-id="contactId"
|
||||
:is-loading="isSearching"
|
||||
@@ -321,6 +264,6 @@ useKeyboardEvents(keyboardEvents);
|
||||
@create-conversation="createConversation"
|
||||
@discard="discardCompose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+2
-2
@@ -20,6 +20,7 @@ const props = defineProps({
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -198,7 +199,6 @@ useEventListener(document, 'paste', onPaste);
|
||||
<WhatsAppOptions
|
||||
v-if="isWhatsappInbox"
|
||||
:inbox-id="inboxId"
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
@@ -220,7 +220,7 @@ useEventListener(document, 'paste', onPaste);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -361,7 +361,7 @@ useKeyboardEvents({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
class="w-full md:w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<ContactSelector
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
|
||||
+65
-55
@@ -6,6 +6,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -22,7 +23,6 @@ const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
@@ -39,29 +39,36 @@ const filteredTemplates = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
const handlePopoverShow = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
const handleSendMessage = (template, hide) => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
hide();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
@@ -69,56 +76,59 @@ const handleSendMessage = template => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+60
-51
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -24,8 +25,6 @@ const getFilteredWhatsAppTemplates = useMapGetter(
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const whatsAppTemplateMessages = computed(() => {
|
||||
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||
});
|
||||
@@ -40,29 +39,36 @@ const getTemplateBody = template => {
|
||||
return template.components.find(component => component.type === 'BODY').text;
|
||||
};
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
const handlePopoverShow = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handlePopoverHide = () => {
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
const handleSendMessage = (template, hide) => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
hide();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Popover
|
||||
align="start"
|
||||
disable-mobile-view
|
||||
@show="handlePopoverShow"
|
||||
@hide="handlePopoverHide"
|
||||
>
|
||||
<Button
|
||||
icon="i-ri-whatsapp-line"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
|
||||
@@ -70,50 +76,53 @@ const handleSendMessage = template => {
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
v-if="!selectedTemplate"
|
||||
class="flex flex-col gap-2 p-4 items-center w-[21.875rem]"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
<div class="relative w-full">
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute size-3.5 top-2 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
class="w-full h-8 py-2 ltr:pl-10 rtl:pr-10 ltr:pr-2 rtl:pl-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ getTemplateBody(template) }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE')
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
<WhatsappTemplate
|
||||
v-else
|
||||
:template="selectedTemplate"
|
||||
@send-message="payload => handleSendMessage(payload, hide)"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
+1
-3
@@ -24,9 +24,7 @@ const handleBack = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem]">
|
||||
<div class="w-full">
|
||||
<WhatsAppTemplateParser
|
||||
:template="template"
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
align: {
|
||||
type: String,
|
||||
default: 'end',
|
||||
validator: v => ['start', 'end'].includes(v),
|
||||
},
|
||||
disableMobileView: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['show', 'hide']);
|
||||
|
||||
const isActive = ref(false);
|
||||
const triggerRef = ref(null);
|
||||
const popoverRef = ref(null);
|
||||
const mobileContentRef = ref(null);
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind);
|
||||
const belowMd = breakpoints.smaller('md');
|
||||
const isMobile = computed(() => !props.disableMobileView && belowMd.value);
|
||||
const showPopover = computed(() => isActive.value && !isMobile.value);
|
||||
|
||||
const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
triggerRef,
|
||||
popoverRef,
|
||||
showPopover,
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
}
|
||||
emit('show');
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
if (!isActive.value) return;
|
||||
isActive.value = false;
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
};
|
||||
|
||||
// Recalculate position when switching from mobile to desktop while open
|
||||
watch(isMobile, async mobile => {
|
||||
if (!isActive.value || mobile) return;
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
});
|
||||
|
||||
const handleClickOutside = event => {
|
||||
if (triggerRef.value?.contains(event.target)) return;
|
||||
hide();
|
||||
};
|
||||
|
||||
// Selectors for teleported elements that should not trigger close
|
||||
const clickOutsideIgnore = [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
];
|
||||
|
||||
useKeyboardEvents({
|
||||
Escape: {
|
||||
action: () => isActive.value && hide(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
|
||||
defineExpose({ show, hide, toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span ref="triggerRef" class="inline-flex" @click="toggle">
|
||||
<slot :is-open="isActive" />
|
||||
</span>
|
||||
|
||||
<TeleportWithDirection to="body">
|
||||
<!-- Mobile: centered modal with backdrop -->
|
||||
<div
|
||||
v-if="isActive && isMobile"
|
||||
data-popover-backdrop
|
||||
class="fixed inset-0 z-[9999] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center bg-n-alpha-black1"
|
||||
>
|
||||
<div
|
||||
ref="mobileContentRef"
|
||||
v-on-click-outside="[
|
||||
handleClickOutside,
|
||||
{ ignore: clickOutsideIgnore },
|
||||
]"
|
||||
data-popover-content
|
||||
class="relative flex flex-col w-full max-w-lg max-h-[calc(100vh-4rem)] mx-4 bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: fixed popover -->
|
||||
<div
|
||||
v-else-if="showPopover"
|
||||
ref="popoverRef"
|
||||
v-on-click-outside="[handleClickOutside, { ignore: clickOutsideIgnore }]"
|
||||
data-popover-content
|
||||
:class="fixedPosition.class"
|
||||
:style="fixedPosition.style"
|
||||
class="flex flex-col bg-n-alpha-3 backdrop-blur-[100px] shadow-xl rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="flex-1 min-h-0 overflow-y-auto overscroll-contain rounded-xl"
|
||||
:class="{ 'border border-n-strong': showContentBorder }"
|
||||
>
|
||||
<slot name="content" :hide="hide" />
|
||||
</div>
|
||||
</div>
|
||||
</TeleportWithDirection>
|
||||
</template>
|
||||
@@ -10,8 +10,6 @@ import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useWindowSize, useEventListener } from '@vueuse/core';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
@@ -184,15 +182,6 @@ const closeMobileSidebar = () => {
|
||||
emit('closeMobileSidebar');
|
||||
};
|
||||
|
||||
const onComposeOpen = toggleFn => {
|
||||
toggleFn();
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
};
|
||||
|
||||
const onComposeClose = () => {
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
};
|
||||
|
||||
const newReportRoutes = () => [
|
||||
{
|
||||
name: 'Reports Agent',
|
||||
@@ -734,7 +723,13 @@ const menuItems = computed(() => {
|
||||
<aside
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#mobile-sidebar-launcher'] },
|
||||
{
|
||||
ignore: [
|
||||
'#mobile-sidebar-launcher',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-background flex flex-col text-sm pb-px fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 w-[200px] md:w-auto md:relative md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:translate-x-0 ltr:border-r rtl:border-l border-n-weak"
|
||||
:class="[
|
||||
@@ -802,8 +797,8 @@ const menuItems = computed(() => {
|
||||
>
|
||||
<span class="i-lucide-search size-4 text-n-slate-11" />
|
||||
</RouterLink>
|
||||
<ComposeConversation align-position="right" @close="onComposeClose">
|
||||
<template #trigger="{ toggle, isOpen }">
|
||||
<ComposeConversation align="start">
|
||||
<template #trigger="{ isOpen }">
|
||||
<Button
|
||||
icon="i-lucide-pen-line"
|
||||
color="slate"
|
||||
@@ -815,7 +810,6 @@ const menuItems = computed(() => {
|
||||
: '!h-7 !outline-n-weak !text-n-slate-11',
|
||||
{ '!bg-n-alpha-2 dark:!bg-n-slate-9/30': isOpen },
|
||||
]"
|
||||
@click="onComposeOpen(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<script setup>
|
||||
// [TODO] This componet is too big and bulky to be in the same file, we can consider splitting this into multiple
|
||||
// composables and components, useVirtualChatList, useChatlistFilters
|
||||
import {
|
||||
ref,
|
||||
unref,
|
||||
provide,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
defineEmits,
|
||||
} from 'vue';
|
||||
import { ref, unref, provide, computed, watch, onMounted } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import {
|
||||
@@ -17,23 +7,19 @@ import {
|
||||
useFunctionGetter,
|
||||
} from 'dashboard/composables/store.js';
|
||||
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationList from './ConversationList.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';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
import ConversationResolveAttributesModal from 'dashboard/components-next/ConversationWorkflow/ConversationResolveAttributesModal.vue';
|
||||
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import { useBulkActions } from 'dashboard/composables/chatlist/useBulkActions';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import { useTrack } from 'dashboard/composables';
|
||||
@@ -85,10 +71,6 @@ const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const resolveAttributesModalRef = ref(null);
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const activeAssigneeTab = ref(wootConstants.ASSIGNEE_TYPE.ME);
|
||||
const activeStatus = ref(wootConstants.STATUS_TYPE.OPEN);
|
||||
@@ -100,7 +82,6 @@ const chatsOnView = ref([]);
|
||||
const foldersQuery = ref({});
|
||||
const showAddFoldersModal = ref(false);
|
||||
const showDeleteFoldersModal = ref(false);
|
||||
const isContextMenuOpen = ref(false);
|
||||
const appliedFilter = ref([]);
|
||||
const advancedFilterTypes = ref(
|
||||
advancedFilterOptions.map(filter => ({
|
||||
@@ -130,7 +111,6 @@ const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const getTeamFn = useMapGetter('teams/getTeam');
|
||||
const getConversationById = useMapGetter('getConversationById');
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
const {
|
||||
selectedConversations,
|
||||
selectedInboxes,
|
||||
@@ -360,7 +340,7 @@ const conversationList = computed(() => {
|
||||
});
|
||||
|
||||
const showEndOfListMessage = computed(() => {
|
||||
return (
|
||||
return !!(
|
||||
conversationList.value.length &&
|
||||
hasCurrentPageEndReached.value &&
|
||||
!chatListLoading.value
|
||||
@@ -605,14 +585,6 @@ function loadMoreConversations() {
|
||||
}
|
||||
}
|
||||
|
||||
// Use IntersectionObserver instead of @scroll since Virtualizer only emits on user scroll.
|
||||
// If the list doesn’t fill the viewport, loading can stall.
|
||||
// IntersectionObserver triggers as soon as the sentinel is visible.
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
function updateAssigneeTab(selectedTab) {
|
||||
if (activeAssigneeTab.value !== selectedTab) {
|
||||
resetBulkActions();
|
||||
@@ -806,10 +778,6 @@ function allSelectedConversationsStatus(status) {
|
||||
});
|
||||
}
|
||||
|
||||
function onContextMenuToggle(state) {
|
||||
isContextMenuOpen.value = state;
|
||||
}
|
||||
|
||||
function toggleSelectAll(check) {
|
||||
selectAllConversations(check, conversationList);
|
||||
}
|
||||
@@ -857,7 +825,6 @@ provide('assignTeam', onAssignTeam);
|
||||
provide('assignLabels', onAssignLabels);
|
||||
provide('removeLabels', onRemoveLabels);
|
||||
provide('updateConversationStatus', handleResolveConversation);
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
provide('markAsUnread', markAsUnread);
|
||||
provide('markAsRead', markAsRead);
|
||||
provide('assignPriority', assignPriority);
|
||||
@@ -970,43 +937,18 @@ watch(conversationFilters, (newVal, oldVal) => {
|
||||
@assign-labels="onAssignLabels"
|
||||
@assign-team="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item, index }"
|
||||
:data="conversationList"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:data-index="index"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="chatListLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p
|
||||
v-else-if="showEndOfListMessage"
|
||||
class="p-4 text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
<ConversationList
|
||||
:conversation-list="conversationList"
|
||||
:is-loading="chatListLoading"
|
||||
:show-end-of-list-message="showEndOfListMessage"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssigneeInConversationCard"
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@load-more="loadMoreConversations"
|
||||
/>
|
||||
<Dialog
|
||||
ref="deleteConversationDialogRef"
|
||||
type="alert"
|
||||
|
||||
@@ -1,74 +1,244 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch, inject } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
},
|
||||
inject: [
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'assignAgent',
|
||||
'assignTeam',
|
||||
'assignLabels',
|
||||
'removeLabels',
|
||||
'updateConversationStatus',
|
||||
'toggleContextMenu',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'isConversationSelected',
|
||||
'deleteConversation',
|
||||
],
|
||||
props: {
|
||||
source: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
teamId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
conversationType: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
foldersId: {
|
||||
type: [String, Number],
|
||||
default: 0,
|
||||
},
|
||||
showAssignee: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
import ConversationCardExpanded from 'dashboard/components-next/Conversation/ConversationCard/ConversationCardExpanded.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from './widgets/conversation/contextMenu/Index.vue';
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: Object, required: true },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
conversationType: { type: String, default: '' },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showExpanded: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const selectConversation = inject('selectConversation');
|
||||
const deSelectConversation = inject('deSelectConversation');
|
||||
const assignAgent = inject('assignAgent');
|
||||
const assignTeam = inject('assignTeam');
|
||||
const assignLabels = inject('assignLabels');
|
||||
const removeLabels = inject('removeLabels');
|
||||
const updateConversationStatus = inject('updateConversationStatus');
|
||||
const toggleContextMenu = inject('toggleContextMenu');
|
||||
const markAsUnread = inject('markAsUnread');
|
||||
const markAsRead = inject('markAsRead');
|
||||
const assignPriority = inject('assignPriority');
|
||||
const isConversationSelected = inject('isConversationSelected');
|
||||
const deleteConversation = inject('deleteConversation');
|
||||
|
||||
// --- Context menu state (shared by both layouts) ---
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset context menu state when the row is recycled to a different conversation.
|
||||
watch(
|
||||
() => props.source.id,
|
||||
() => {
|
||||
if (showContextMenu.value) {
|
||||
toggleContextMenu(false);
|
||||
}
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
}
|
||||
);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.source.meta || {});
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() =>
|
||||
senderId.value ? store.getters['contacts/getContact'](senderId.value) : {}
|
||||
);
|
||||
|
||||
const isActiveChat = computed(() => currentChat.value.id === props.source.id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
const inboxId = props.source.inbox_id;
|
||||
return inboxId ? store.getters['inboxes/getInbox'](inboxId) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(
|
||||
() => !activeInbox.value && inboxesList.value.length > 1
|
||||
);
|
||||
const isInboxView = computed(() => !!activeInbox.value);
|
||||
const showAssigneeForExpandedCard = computed(
|
||||
() => props.showExpanded || props.showAssignee
|
||||
);
|
||||
|
||||
const conversationPath = computed(() =>
|
||||
frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.source.id,
|
||||
label: props.label,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActiveChat.value) return;
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onExpandedSelect = checked => {
|
||||
if (checked) {
|
||||
selectConversation(props.source.id, inbox.value.id);
|
||||
} else {
|
||||
deSelectConversation(props.source.id, inbox.value.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
e.preventDefault();
|
||||
toggleContextMenu(true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
toggleContextMenu(false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
updateConversationStatus(props.source.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
assignAgent(agent, [props.source.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
assignLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
removeLabels([label.title], [props.source.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
assignTeam(team, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsUnread = () => {
|
||||
markAsUnread(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onMarkAsRead = () => {
|
||||
markAsRead(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignPriority = priority => {
|
||||
assignPriority(priority, props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onDeleteConversation = () => {
|
||||
deleteConversation(props.source.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConversationCard
|
||||
:active-label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
<!-- Expanded layout: wide screen + expanded setting -->
|
||||
<ConversationCardExpanded
|
||||
v-if="showExpanded"
|
||||
:chat="source"
|
||||
:conversation-type="conversationType"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssigneeForExpandedCard"
|
||||
:show-inbox-name="showInboxName"
|
||||
:is-inbox-view="isInboxView"
|
||||
@select-conversation="onExpandedSelect"
|
||||
@de-select-conversation="onExpandedSelect"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
/>
|
||||
|
||||
<!-- Default (condensed) layout -->
|
||||
<ConversationCard
|
||||
v-else
|
||||
:chat="source"
|
||||
:current-contact="currentContact"
|
||||
:assignee="assignee"
|
||||
:inbox="inbox"
|
||||
:selected="isConversationSelected(source.id)"
|
||||
:is-active-chat="isActiveChat"
|
||||
:show-assignee="showAssignee"
|
||||
enable-context-menu
|
||||
:show-inbox-name="showInboxName"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu"
|
||||
@select-conversation="selectConversation"
|
||||
@de-select-conversation="deSelectConversation"
|
||||
@assign-agent="assignAgent"
|
||||
@assign-team="assignTeam"
|
||||
@assign-label="assignLabels"
|
||||
@remove-label="removeLabels"
|
||||
@update-conversation-status="updateConversationStatus"
|
||||
@context-menu-toggle="toggleContextMenu"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
/>
|
||||
|
||||
<!-- Shared context menu for both layouts -->
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="source.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="source.priority"
|
||||
:chat-id="source.id"
|
||||
:has-unread-messages="source.unread_count > 0"
|
||||
:conversation-labels="source.labels"
|
||||
:conversation-url="conversationPath"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="onMarkAsUnread"
|
||||
@mark-as-read="onMarkAsRead"
|
||||
@assign-priority="onAssignPriority"
|
||||
@delete-conversation="onDeleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, computed, provide } from 'vue';
|
||||
import { Virtualizer } from 'virtua/vue';
|
||||
import { useBreakpoints } from '@vueuse/core';
|
||||
import { useChatListKeyboardEvents } from 'dashboard/composables/chatlist/useChatListKeyboardEvents';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const props = defineProps({
|
||||
conversationList: { type: Array, default: () => [] },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
showEndOfListMessage: { type: Boolean, default: false },
|
||||
label: { type: String, default: '' },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
conversationType: { type: String, default: '' },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
isOnExpandedLayout: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['loadMore']);
|
||||
|
||||
const conversationListRef = ref(null);
|
||||
const virtualListRef = ref(null);
|
||||
const isContextMenuOpen = ref(false);
|
||||
|
||||
provide('contextMenuElementTarget', virtualListRef);
|
||||
|
||||
const breakpoints = useBreakpoints({
|
||||
lg: wootConstants.LARGE_SCREEN_BREAKPOINT,
|
||||
});
|
||||
const isLgScreen = breakpoints.greaterOrEqual('lg');
|
||||
const showExpandedCards = computed(
|
||||
() => props.isOnExpandedLayout && isLgScreen.value
|
||||
);
|
||||
|
||||
useChatListKeyboardEvents(conversationListRef);
|
||||
|
||||
const intersectionObserverOptions = computed(() => ({
|
||||
root: conversationListRef.value,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
}));
|
||||
|
||||
const onContextMenuToggle = state => {
|
||||
isContextMenuOpen.value = state;
|
||||
};
|
||||
|
||||
const loadMoreConversations = () => {
|
||||
emit('loadMore');
|
||||
};
|
||||
|
||||
provide('toggleContextMenu', onContextMenuToggle);
|
||||
|
||||
defineExpose({ conversationListRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="conversationListRef"
|
||||
class="flex-1 min-h-0 overflow-y-auto conversations-list"
|
||||
:class="{ '!overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
<Virtualizer
|
||||
ref="virtualListRef"
|
||||
v-slot="{ item }"
|
||||
:data="conversationList"
|
||||
class="[&>div:has(+_div_.active)>*]:!border-n-surface-1 [&>div:has(+_div_.selected)>*]:!border-n-surface-1"
|
||||
>
|
||||
<ConversationItem
|
||||
:source="item"
|
||||
:label="label"
|
||||
:team-id="teamId"
|
||||
:folders-id="foldersId"
|
||||
:conversation-type="conversationType"
|
||||
:show-assignee="showAssignee"
|
||||
:show-expanded="showExpandedCards"
|
||||
/>
|
||||
</Virtualizer>
|
||||
<div v-if="isLoading" class="flex justify-center my-4">
|
||||
<Spinner class="text-n-brand" />
|
||||
</div>
|
||||
<p v-else-if="showEndOfListMessage" class="p-4 text-center text-n-slate-11">
|
||||
{{ $t('CHAT_LIST.EOF') }}
|
||||
</p>
|
||||
<IntersectionObserver
|
||||
v-else
|
||||
:options="intersectionObserverOptions"
|
||||
@observed="loadMoreConversations"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,98 +1,41 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import CardPriorityIcon from 'dashboard/components-next/Conversation/ConversationCard/CardPriorityIcon.vue';
|
||||
import UnreadBadge from 'dashboard/components-next/Conversation/ConversationCard/UnreadBadge.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
chat: { type: Object, default: () => ({}) },
|
||||
hideInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
teamId: { type: [String, Number], default: 0 },
|
||||
foldersId: { type: [String, Number], default: 0 },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
conversationType: { type: String, default: '' },
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
enableContextMenu: { type: Boolean, default: false },
|
||||
allowedContextMenuOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'contextMenuToggle',
|
||||
'assignAgent',
|
||||
'assignLabel',
|
||||
'removeLabel',
|
||||
'assignTeam',
|
||||
'markAsUnread',
|
||||
'markAsRead',
|
||||
'assignPriority',
|
||||
'updateConversationStatus',
|
||||
'deleteConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const hovered = ref(false);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
// Reset UI state when conversation changes at same index (no :key, instance reused on reorder)
|
||||
// This prevents context menu/hover state from leaking to a different conversation
|
||||
// Emit contextMenuToggle(false) to sync parent state if menu was open during recycling
|
||||
const resetState = () => {
|
||||
if (showContextMenu.value) {
|
||||
emit('contextMenuToggle', false);
|
||||
}
|
||||
hovered.value = false;
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value = { x: null, y: null };
|
||||
};
|
||||
|
||||
watch(() => props.chat.id, resetState);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
|
||||
const chatMetadata = computed(() => props.chat.meta || {});
|
||||
|
||||
const assignee = computed(() => chatMetadata.value.assignee || {});
|
||||
|
||||
const senderId = computed(() => chatMetadata.value.sender?.id);
|
||||
|
||||
const currentContact = computed(() => {
|
||||
return senderId.value
|
||||
? store.getters['contacts/getContact'](senderId.value)
|
||||
: {};
|
||||
});
|
||||
|
||||
const isActiveChat = computed(() => {
|
||||
return currentChat.value.id === props.chat.id;
|
||||
});
|
||||
|
||||
const unreadCount = computed(() => props.chat.unread_count);
|
||||
|
||||
const hasUnread = computed(() => unreadCount.value > 0);
|
||||
|
||||
const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => ({
|
||||
@@ -100,24 +43,10 @@ const voiceCallData = computed(() => ({
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
const inbox = computed(() => {
|
||||
return inboxId.value ? store.getters['inboxes/getInbox'](inboxId.value) : {};
|
||||
});
|
||||
|
||||
const showInboxName = computed(() => {
|
||||
return (
|
||||
!props.hideInboxName &&
|
||||
isInboxNameVisible.value &&
|
||||
inboxesList.value.length > 1
|
||||
);
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
showInboxName.value ||
|
||||
(props.showAssignee && assignee.value.name) ||
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
@@ -136,41 +65,6 @@ const messagePreviewClass = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId: accountId.value,
|
||||
activeInbox: activeInbox.value,
|
||||
id: props.chat.id,
|
||||
label: props.activeLabel,
|
||||
teamId: props.teamId,
|
||||
conversationType: props.conversationType,
|
||||
foldersId: props.foldersId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const onCardClick = e => {
|
||||
const path = conversationPath.value;
|
||||
if (!path) return;
|
||||
|
||||
// Handle Ctrl/Cmd + Click for new tab
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already active
|
||||
if (isActiveChat.value) return;
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
@@ -181,83 +75,37 @@ const onThumbnailLeave = () => {
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, inbox.value.id);
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, inbox.value.id);
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
};
|
||||
|
||||
const openContextMenu = e => {
|
||||
if (!props.enableContextMenu) return;
|
||||
e.preventDefault();
|
||||
emit('contextMenuToggle', true);
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
});
|
||||
|
||||
const closeContextMenu = () => {
|
||||
emit('contextMenuToggle', false);
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
};
|
||||
|
||||
const onUpdateConversation = (status, snoozedUntil) => {
|
||||
closeContextMenu();
|
||||
emit('updateConversationStatus', props.chat.id, status, snoozedUntil);
|
||||
};
|
||||
|
||||
const onAssignAgent = agent => {
|
||||
emit('assignAgent', agent, [props.chat.id]);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const onAssignLabel = label => {
|
||||
emit('assignLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onRemoveLabel = label => {
|
||||
emit('removeLabel', [label.title], [props.chat.id]);
|
||||
};
|
||||
|
||||
const onAssignTeam = team => {
|
||||
emit('assignTeam', team, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsUnread = () => {
|
||||
emit('markAsUnread', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const markAsRead = () => {
|
||||
emit('markAsRead', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const assignPriority = priority => {
|
||||
emit('assignPriority', priority, props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const deleteConversation = () => {
|
||||
emit('deleteConversation', props.chat.id);
|
||||
closeContextMenu();
|
||||
};
|
||||
watch(
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 border-t-0 border-b-0 border-l-0 border-r-0 border-transparent border-solid cursor-pointer conversation hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group"
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background border-n-weak': isActiveChat,
|
||||
'bg-n-slate-2': selected,
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="onCardClick"
|
||||
@contextmenu="openContextMenu($event)"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@@ -272,7 +120,6 @@ const deleteConversation = () => {
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
@@ -281,20 +128,12 @@ const deleteConversation = () => {
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<input
|
||||
:value="selected"
|
||||
:checked="selected"
|
||||
class="!m-0 cursor-pointer"
|
||||
type="checkbox"
|
||||
@change="onSelectConversation($event.target.checked)"
|
||||
/>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div
|
||||
class="px-0 py-3 border-b group-hover:border-transparent flex-1 border-n-slate-3 min-w-0"
|
||||
>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
@@ -369,12 +208,11 @@ const deleteConversation = () => {
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
class="shadow-lg rounded-full text-xxs font-semibold h-4 leading-4 ltr:ml-auto rtl:mr-auto mt-1 min-w-[1rem] px-1 py-0 text-center text-white bg-n-teal-9"
|
||||
:class="hasUnread ? 'block' : 'hidden'"
|
||||
>
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
<UnreadBadge
|
||||
v-if="hasUnread"
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
@@ -386,32 +224,5 @@ const deleteConversation = () => {
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="chat.status"
|
||||
:inbox-id="inbox.id"
|
||||
:priority="chat.priority"
|
||||
:chat-id="chat.id"
|
||||
:has-unread-messages="hasUnread"
|
||||
:conversation-labels="chat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="allowedContextMenuOptions"
|
||||
@update-conversation="onUpdateConversation"
|
||||
@assign-agent="onAssignAgent"
|
||||
@assign-label="onAssignLabel"
|
||||
@remove-label="onRemoveLabel"
|
||||
@assign-team="onAssignTeam"
|
||||
@mark-as-unread="markAsUnread"
|
||||
@mark-as-read="markAsRead"
|
||||
@assign-priority="assignPriority"
|
||||
@delete-conversation="deleteConversation"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,7 +41,16 @@ const closeContactPanel = () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-on-click-outside="() => closeContactPanel()"
|
||||
v-on-click-outside="[
|
||||
() => closeContactPanel(),
|
||||
{
|
||||
ignore: [
|
||||
'dialog.ProseMirror-prompt-backdrop',
|
||||
'[data-popover-content]',
|
||||
'[data-popover-backdrop]',
|
||||
],
|
||||
},
|
||||
]"
|
||||
class="bg-n-surface-2 h-full overflow-hidden flex flex-col fixed top-0 z-40 w-full max-w-sm transition-transform duration-300 ease-in-out ltr:right-0 rtl:left-0 md:static md:w-[320px] md:min-w-[320px] ltr:border-l rtl:border-r border-n-weak 2xl:min-w-[360px] 2xl:w-[360px] shadow-lg md:shadow-none"
|
||||
:class="[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { ref } from 'vue';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
|
||||
// Mock @vueuse/core — return reactive refs we can control per test
|
||||
const mockBounding = () => ({
|
||||
top: ref(0),
|
||||
bottom: ref(0),
|
||||
left: ref(0),
|
||||
right: ref(0),
|
||||
width: ref(0),
|
||||
height: ref(0),
|
||||
update: vi.fn(),
|
||||
});
|
||||
|
||||
const triggerBounds = mockBounding();
|
||||
const dropdownBounds = mockBounding();
|
||||
const winWidth = ref(1024);
|
||||
const winHeight = ref(768);
|
||||
|
||||
vi.mock('@vueuse/core', () => {
|
||||
let callCount = 0;
|
||||
return {
|
||||
// First call = trigger, second = dropdown, third = container (if any)
|
||||
useElementBounding: () => {
|
||||
callCount += 1;
|
||||
return callCount % 3 === 1 ? triggerBounds : dropdownBounds;
|
||||
},
|
||||
useWindowSize: () => ({ width: winWidth, height: winHeight }),
|
||||
};
|
||||
});
|
||||
|
||||
const setTrigger = ({ top, bottom, left, right }) => {
|
||||
triggerBounds.top.value = top;
|
||||
triggerBounds.bottom.value = bottom;
|
||||
triggerBounds.left.value = left ?? 100;
|
||||
triggerBounds.right.value = right ?? 200;
|
||||
};
|
||||
|
||||
const setDropdown = ({ width, height }) => {
|
||||
dropdownBounds.width.value = width ?? 200;
|
||||
dropdownBounds.height.value = height;
|
||||
};
|
||||
|
||||
describe('useDropdownPosition', () => {
|
||||
beforeEach(() => {
|
||||
winWidth.value = 1024;
|
||||
winHeight.value = 768;
|
||||
document.body.innerHTML = '<div id="app" dir="ltr"></div>';
|
||||
});
|
||||
|
||||
describe('verticalClass (relative mode)', () => {
|
||||
it('places below when enough space', () => {
|
||||
// Trigger at y=100, dropdown 200px tall
|
||||
// Space below = 768 - 140 = 628 → fits (628 > 216)
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('places above when not enough space below but enough above', () => {
|
||||
// Trigger near bottom at y=600
|
||||
// Space below = 128 → doesn't fit. Space above = 600 → fits
|
||||
setTrigger({ top: 600, bottom: 640 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('picks the side with more space when dropdown fits neither', () => {
|
||||
// Dropdown 500px tall, won't fit above (300) or below (428)
|
||||
// Below has more room → stays below
|
||||
setTrigger({ top: 300, bottom: 340 });
|
||||
setDropdown({ height: 500 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
});
|
||||
|
||||
it('picks above when above has more space and neither fits', () => {
|
||||
// Dropdown 600px tall, won't fit above (500) or below (228)
|
||||
// Above has more room → flips above
|
||||
setTrigger({ top: 500, bottom: 540 });
|
||||
setDropdown({ height: 600 });
|
||||
|
||||
const { position } = useDropdownPosition(ref(null), ref(null), ref(true));
|
||||
expect(position.value.class).toBe('bottom-full mb-2');
|
||||
});
|
||||
|
||||
it('returns default when disabled', () => {
|
||||
setTrigger({ top: 700, bottom: 740 });
|
||||
setDropdown({ height: 200 });
|
||||
|
||||
const { position } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(position.value.class).toBe('top-full mt-2');
|
||||
expect(position.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixedPosition', () => {
|
||||
it('places below with correct top and maxHeight', () => {
|
||||
// Trigger at y=140, space below = 628
|
||||
// top = 140 + 8(gap) = 148
|
||||
// maxHeight = 628 - 8(gap) - 16(margin) = 604
|
||||
setTrigger({ top: 100, bottom: 140 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.top).toBe('148px');
|
||||
expect(fixedPosition.value.style.bottom).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('604px');
|
||||
});
|
||||
|
||||
it('flips above with correct bottom and maxHeight', () => {
|
||||
// Trigger near bottom at y=650, space below = 78 → doesn't fit
|
||||
// Flips above: bottom = 768 - 650 + 8 = 126
|
||||
// maxHeight = 650 - 8 - 16 = 626
|
||||
setTrigger({ top: 650, bottom: 690 });
|
||||
setDropdown({ height: 200, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBe('126px');
|
||||
expect(fixedPosition.value.style.top).toBeUndefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('626px');
|
||||
});
|
||||
|
||||
it('constrains maxHeight to available space on short viewports', () => {
|
||||
// Short viewport (400px), trigger in the middle, dropdown 500px tall
|
||||
// Neither side fits → above (200) > below (160) → places above
|
||||
// maxHeight capped to 200 - 8 - 16 = 176
|
||||
winHeight.value = 400;
|
||||
setTrigger({ top: 200, bottom: 240 });
|
||||
setDropdown({ height: 500, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.bottom).toBeDefined();
|
||||
expect(fixedPosition.value.style.maxHeight).toBe('176px');
|
||||
});
|
||||
|
||||
it('returns defaults when disabled', () => {
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(false)
|
||||
);
|
||||
expect(fixedPosition.value.class).toBe('fixed z-[9999]');
|
||||
expect(fixedPosition.value.style).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('horizontal positioning (fixedPosition)', () => {
|
||||
it('anchors to the right edge by default (align=end, LTR)', () => {
|
||||
// align=end + LTR → anchorLeft=false → uses style.right
|
||||
// right = 1024 - 900 = 124
|
||||
setTrigger({ top: 100, bottom: 140, left: 800, right: 900 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('124px');
|
||||
});
|
||||
|
||||
it('anchors to the left edge when align=start (LTR)', () => {
|
||||
// align=start + LTR → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
|
||||
it('shifts left when dropdown overflows right edge', () => {
|
||||
// Trigger at x=900, dropdown 300px wide → 900+300=1200 > 1024
|
||||
// Falls back to right: 16px (margin)
|
||||
setTrigger({ top: 100, bottom: 140, left: 900, right: 1000 });
|
||||
setDropdown({ height: 100, width: 300 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true),
|
||||
{ align: 'start' }
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.right).toBe('16px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RTL', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '<div id="app" dir="rtl"></div>';
|
||||
});
|
||||
|
||||
it('flips anchor direction in RTL (align=end anchors left)', () => {
|
||||
// align=end + RTL → anchorLeft=true → uses style.left
|
||||
setTrigger({ top: 100, bottom: 140, left: 100, right: 200 });
|
||||
setDropdown({ height: 100, width: 200 });
|
||||
|
||||
const { fixedPosition } = useDropdownPosition(
|
||||
ref(null),
|
||||
ref(null),
|
||||
ref(true)
|
||||
);
|
||||
|
||||
expect(fixedPosition.value.style.left).toBe('100px');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { computed, unref, watch } from 'vue';
|
||||
import { useElementBounding, useWindowSize } from '@vueuse/core';
|
||||
|
||||
const FALLBACK_SIZE = 200;
|
||||
const SAFE_MARGIN = 16;
|
||||
const GAP = 8;
|
||||
|
||||
/**
|
||||
* Auto-position a floating element based on available viewport space.
|
||||
*
|
||||
* @param {Ref} triggerRef - Trigger element ref
|
||||
* @param {Ref} dropdownRef - Dropdown/popover element ref
|
||||
* @param {Ref} enabled - Whether to calculate position
|
||||
* @param {Object} options
|
||||
* @param {Ref} [options.container] - Constraining container ref
|
||||
* @param {number} [options.margin=16] - Min distance from viewport/container edges
|
||||
* @param {string} [options.align='end'] - 'start' or 'end' (flips automatically for RTL)
|
||||
*/
|
||||
export function useDropdownPosition(
|
||||
triggerRef,
|
||||
dropdownRef,
|
||||
enabled,
|
||||
{ container = null, margin = SAFE_MARGIN, align = 'end' } = {}
|
||||
) {
|
||||
const trigger = useElementBounding(triggerRef);
|
||||
const dropdown = useElementBounding(dropdownRef);
|
||||
const bounds = useElementBounding(container);
|
||||
const { width: winWidth, height: winHeight } = useWindowSize();
|
||||
|
||||
const isRTL = computed(
|
||||
() => document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl'
|
||||
);
|
||||
|
||||
// Whether to anchor to the left edge of the trigger
|
||||
const anchorLeft = computed(() => (align === 'start') !== isRTL.value);
|
||||
|
||||
const verticalClass = computed(() => {
|
||||
if (!unref(enabled)) return 'top-full mt-2';
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const spaceAbove = trigger.top.value;
|
||||
// Only flip above if it fits there; otherwise stay below (more room or equal)
|
||||
if (spaceBelow >= dh + margin) return 'top-full mt-2';
|
||||
if (spaceAbove >= dh + margin) return 'bottom-full mb-2';
|
||||
return spaceBelow >= spaceAbove ? 'top-full mt-2' : 'bottom-full mb-2';
|
||||
});
|
||||
|
||||
// Relative mode: Tailwind class + style for absolute-in-parent dropdowns
|
||||
const position = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'top-full mt-2', style: {} };
|
||||
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const leftBound = container ? bounds.left.value : 0;
|
||||
const rightBound = container ? bounds.right.value : winWidth.value;
|
||||
const style = {};
|
||||
|
||||
if (anchorLeft.value) {
|
||||
const available = rightBound - trigger.left.value;
|
||||
const overflow = dw - available;
|
||||
style.left = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
} else {
|
||||
const available = trigger.right.value - leftBound;
|
||||
const overflow = dw - available;
|
||||
style.right = overflow > 0 ? `-${overflow}px` : '0px';
|
||||
}
|
||||
|
||||
return { class: verticalClass.value, style };
|
||||
});
|
||||
|
||||
// Fixed mode: styles for teleported popovers
|
||||
const fixedPosition = computed(() => {
|
||||
if (!unref(enabled)) return { class: 'fixed z-[9999]', style: {} };
|
||||
|
||||
const dh = dropdown.height.value || FALLBACK_SIZE;
|
||||
const dw = dropdown.width.value || FALLBACK_SIZE;
|
||||
const spaceBelow = winHeight.value - trigger.bottom.value;
|
||||
const style = {};
|
||||
|
||||
// Vertical: prefer below, flip above only if it fits, else pick the larger side
|
||||
const spaceAbove = trigger.top.value;
|
||||
const placeAbove =
|
||||
spaceBelow < dh + margin &&
|
||||
(spaceAbove >= dh + margin || spaceAbove > spaceBelow);
|
||||
|
||||
if (placeAbove) {
|
||||
style.bottom = `${winHeight.value - trigger.top.value + GAP}px`;
|
||||
style.maxHeight = `${spaceAbove - GAP - margin}px`;
|
||||
} else {
|
||||
style.top = `${trigger.bottom.value + GAP}px`;
|
||||
style.maxHeight = `${spaceBelow - GAP - margin}px`;
|
||||
}
|
||||
|
||||
// Horizontal
|
||||
if (anchorLeft.value) {
|
||||
const left = trigger.left.value;
|
||||
if (left + dw > winWidth.value - margin) {
|
||||
style.right = `${margin}px`;
|
||||
} else {
|
||||
style.left = `${Math.max(margin, left)}px`;
|
||||
}
|
||||
} else {
|
||||
const right = winWidth.value - trigger.right.value;
|
||||
if (trigger.right.value - dw < margin) {
|
||||
style.left = `${margin}px`;
|
||||
} else {
|
||||
style.right = `${right}px`;
|
||||
}
|
||||
}
|
||||
|
||||
return { class: 'fixed z-[9999]', style };
|
||||
});
|
||||
|
||||
const updatePosition = () => {
|
||||
trigger.update();
|
||||
dropdown.update();
|
||||
if (container) bounds.update();
|
||||
};
|
||||
|
||||
// Update position when dropdown opens to ensure RTL state is current
|
||||
watch(
|
||||
() => unref(enabled),
|
||||
isEnabled => {
|
||||
if (isEnabled) updatePosition();
|
||||
}
|
||||
);
|
||||
|
||||
return { position, fixedPosition, updatePosition };
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export default {
|
||||
WHATSAPP_EMBEDDED_SIGNUP_DOCS_URL:
|
||||
'https://developers.facebook.com/docs/whatsapp/embedded-signup/custom-flows/onboarding-business-app-users#limitations',
|
||||
SMALL_SCREEN_BREAKPOINT: 768,
|
||||
LARGE_SCREEN_BREAKPOINT: 1024,
|
||||
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
|
||||
SNOOZE_OPTIONS: {
|
||||
UNTIL_NEXT_REPLY: 'until_next_reply',
|
||||
|
||||
@@ -91,6 +91,13 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
action: 'archive',
|
||||
icon: 'i-lucide-archive-restore',
|
||||
},
|
||||
translate: {
|
||||
label:
|
||||
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.TRANSLATE',
|
||||
value: 'translate',
|
||||
action: 'translate',
|
||||
icon: 'i-lucide-languages',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
|
||||
value: 'delete',
|
||||
@@ -100,9 +107,9 @@ export const ARTICLE_MENU_ITEMS = {
|
||||
};
|
||||
|
||||
export const ARTICLE_MENU_OPTIONS = {
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
|
||||
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft', 'translate'],
|
||||
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive', 'translate'],
|
||||
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive', 'translate'],
|
||||
};
|
||||
|
||||
export const ARTICLE_TABS = {
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
|
||||
"SHOW_QUOTED_TEXT": "Show Quoted Text",
|
||||
"MESSAGE_READ": "Read",
|
||||
"SENDING": "Sending"
|
||||
"SENDING": "Sending",
|
||||
"UNREAD_COUNT_OVERFLOW": "9+"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
"REPLY_MESSAGE_NOT_FOUND": "Message not available",
|
||||
"CARD": {
|
||||
"SHOW_LABELS": "Show labels",
|
||||
"HIDE_LABELS": "Hide labels"
|
||||
"HIDE_LABELS": "Hide labels",
|
||||
"LABELS_COUNT": "{count} labels"
|
||||
},
|
||||
"VOICE_CALL": {
|
||||
"INCOMING_CALL": "Incoming call",
|
||||
|
||||
@@ -525,6 +525,7 @@
|
||||
"PUBLISH": "Publish",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"DELETE": "Delete"
|
||||
},
|
||||
"STATUS": {
|
||||
@@ -579,6 +580,41 @@
|
||||
"TITLE": "There are no articles in this category",
|
||||
"SUBTITLE": "Articles in this category will appear here"
|
||||
}
|
||||
},
|
||||
"BULK_TRANSLATE": {
|
||||
"TITLE": "Translate article | Translate {count} articles",
|
||||
"DESCRIPTION": "Translate the selected article to another language. | Translate the selected articles to another language.",
|
||||
"LOCALE_LABEL": "Target language",
|
||||
"LOCALE_PLACEHOLDER": "Select a language",
|
||||
"CATEGORY_LABEL": "Target category",
|
||||
"CATEGORY_PLACEHOLDER": "Select a category",
|
||||
"OPTIONAL": "(optional)",
|
||||
"CONFIRM": "Translate",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"TRANSLATE_BUTTON": "Translate",
|
||||
"CONFIRM_OVERWRITE": "Overwrite and translate",
|
||||
"DUPLICATE_WARNING": "A translation already exists for this article in the selected language. | Translations already exist for {count} articles in the selected language.",
|
||||
"DUPLICATE_CONFIRM_HINT": "Click translate again to overwrite the existing translation.",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Translation in progress. The article will appear as a draft once ready.",
|
||||
"ERROR_MESSAGE": "Failed to start translation. Please try again."
|
||||
}
|
||||
},
|
||||
"BULK_ACTIONS": {
|
||||
"PUBLISH": "Publish",
|
||||
"DRAFT": "Draft",
|
||||
"ARCHIVE": "Archive",
|
||||
"TRANSLATE": "Translate",
|
||||
"DELETE": "Delete",
|
||||
"STATUS_SUCCESS": "Articles updated successfully",
|
||||
"STATUS_ERROR": "Failed to update articles",
|
||||
"DELETE_CONFIRM_TITLE": "Delete article | Delete {count} articles",
|
||||
"DELETE_CONFIRM_DESCRIPTION": "This will permanently delete the selected article. This action cannot be undone. | This will permanently delete {count} selected articles. This action cannot be undone.",
|
||||
"DELETE_CONFIRM": "Delete",
|
||||
"DELETE_SUCCESS": "Articles deleted successfully",
|
||||
"DELETE_ERROR": "Failed to delete articles"
|
||||
}
|
||||
},
|
||||
"CATEGORY_PAGE": {
|
||||
|
||||
@@ -745,6 +745,7 @@
|
||||
"SENDER_NAME_SECTION_TEXT": "Enable/Disable showing Agent's name in email, if disabled it will show business name",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL": "Enable conversation continuity via email",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT": "Conversations will continue over email if the contact email address is available.",
|
||||
"ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT": "This feature is available on a paid plan. Upgrade to enable conversation continuity via email.",
|
||||
"LOCK_TO_SINGLE_CONVERSATION": "Conversation Routing",
|
||||
"LOCK_TO_SINGLE_CONVERSATION_SUB_TEXT": "Configure conversation creation for existing contacts",
|
||||
"INBOX_UPDATE_TITLE": "Inbox Settings",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
getConversationDashboardRoute,
|
||||
} from 'dashboard/helper/routeHelpers';
|
||||
|
||||
const props = defineProps({
|
||||
contact: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'deleted']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const confirmMessage = computed(
|
||||
() => `${t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${props.contact.name}?`
|
||||
);
|
||||
|
||||
const onDelete = async hide => {
|
||||
try {
|
||||
await store.dispatch('contacts/delete', props.contact.id);
|
||||
useAlert(t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
|
||||
hide();
|
||||
emit('deleted');
|
||||
emit('close');
|
||||
|
||||
if (isAConversationRoute(route.name)) {
|
||||
router.push({ name: getConversationDashboardRoute(route.name) });
|
||||
} else if (isAInboxViewRoute(route.name)) {
|
||||
router.push({ name: 'inbox_view' });
|
||||
} else if (route.name !== 'contacts_dashboard') {
|
||||
router.push({ name: 'contacts_dashboard' });
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(error.message || t('DELETE_CONTACT.API.ERROR_MESSAGE'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover @hide="$emit('close')">
|
||||
<slot name="trigger" />
|
||||
<template #content="{ hide }">
|
||||
<div class="w-full md:w-80 p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('DELETE_CONTACT.CONFIRM.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ confirmMessage }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
faded
|
||||
slate
|
||||
sm
|
||||
:label="$t('DELETE_CONTACT.CONFIRM.NO')"
|
||||
@click="hide"
|
||||
/>
|
||||
<Button
|
||||
ruby
|
||||
sm
|
||||
:label="$t('DELETE_CONTACT.CONFIRM.YES')"
|
||||
:is-loading="uiFlags.isDeleting"
|
||||
:disabled="uiFlags.isDeleting"
|
||||
@click="onDelete(hide)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -5,8 +5,8 @@ import { useStore } from 'vuex';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Popover from 'dashboard/components-next/popover/Popover.vue';
|
||||
import MergeContact from 'dashboard/modules/contact/components/MergeContact.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import { CONTACTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
|
||||
@@ -23,7 +23,6 @@ const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isSearching = ref(false);
|
||||
const searchResults = ref([]);
|
||||
|
||||
@@ -35,21 +34,6 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
const open = () => {
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
dialogRef.value?.close();
|
||||
};
|
||||
|
||||
defineExpose({ open, close });
|
||||
|
||||
const onClose = () => {
|
||||
close();
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onContactSearch = async query => {
|
||||
isSearching.value = true;
|
||||
searchResults.value = [];
|
||||
@@ -68,7 +52,7 @@ const onContactSearch = async query => {
|
||||
}
|
||||
};
|
||||
|
||||
const onMergeContacts = async parentContactId => {
|
||||
const onMergeContacts = async (parentContactId, hide) => {
|
||||
useTrack(CONTACTS_EVENTS.MERGED_CONTACTS);
|
||||
try {
|
||||
await store.dispatch('contacts/merge', {
|
||||
@@ -76,7 +60,7 @@ const onMergeContacts = async parentContactId => {
|
||||
parentId: parentContactId,
|
||||
});
|
||||
useAlert(t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
|
||||
close();
|
||||
hide();
|
||||
emit('close');
|
||||
} catch (error) {
|
||||
useAlert(t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
|
||||
@@ -85,24 +69,29 @@ const onMergeContacts = async parentContactId => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="edit"
|
||||
width="2xl"
|
||||
:title="$t('MERGE_CONTACTS.TITLE')"
|
||||
:description="$t('MERGE_CONTACTS.DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
:show-confirm-button="false"
|
||||
>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
:search-results="searchResults"
|
||||
@search="onContactSearch"
|
||||
@cancel="onClose"
|
||||
@submit="onMergeContacts"
|
||||
/>
|
||||
</Dialog>
|
||||
<Popover @hide="$emit('close')">
|
||||
<slot name="trigger" />
|
||||
<template #content="{ hide }">
|
||||
<div class="w-full md:w-96 p-6 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-base font-medium leading-6 text-n-slate-12">
|
||||
{{ $t('MERGE_CONTACTS.TITLE') }}
|
||||
</h3>
|
||||
<p class="mb-0 text-sm text-n-slate-11">
|
||||
{{ $t('MERGE_CONTACTS.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<MergeContact
|
||||
:key="primaryContact.id"
|
||||
:primary-contact="primaryContact"
|
||||
:is-searching="isSearching"
|
||||
:is-merging="uiFlags.isMerging"
|
||||
:search-results="searchResults"
|
||||
@search="onContactSearch"
|
||||
@cancel="hide"
|
||||
@submit="id => onMergeContacts(id, hide)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
@@ -1,49 +1,131 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
isOnFoldersView,
|
||||
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
|
||||
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ConversationCard,
|
||||
Spinner,
|
||||
},
|
||||
props: {
|
||||
contactId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
conversationId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
conversations() {
|
||||
return this.$store.getters['contactConversations/getContactConversation'](
|
||||
this.contactId
|
||||
);
|
||||
},
|
||||
previousConversations() {
|
||||
return this.conversations.filter(
|
||||
conversation => conversation.id !== Number(this.conversationId)
|
||||
);
|
||||
},
|
||||
...mapGetters({
|
||||
uiFlags: 'contactConversations/getUIFlags',
|
||||
}),
|
||||
},
|
||||
watch: {
|
||||
contactId(newContactId, prevContactId) {
|
||||
if (newContactId && newContactId !== prevContactId) {
|
||||
this.$store.dispatch('contactConversations/get', newContactId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('contactConversations/get', this.contactId);
|
||||
},
|
||||
const props = defineProps({
|
||||
contactId: { type: [String, Number], required: true },
|
||||
conversationId: { type: [String, Number], required: true },
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const uiFlags = useMapGetter('contactConversations/getUIFlags');
|
||||
|
||||
const contactGetter = useMapGetter('contacts/getContact');
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const activeInbox = useMapGetter('getSelectedInbox');
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const showInboxName = computed(
|
||||
() => !activeInbox.value && inboxesList.value.length > 1
|
||||
);
|
||||
|
||||
const contactConversationGetter = useMapGetter(
|
||||
'contactConversations/getContactConversation'
|
||||
);
|
||||
const conversations = computed(() =>
|
||||
contactConversationGetter.value(props.contactId)
|
||||
);
|
||||
|
||||
const previousConversations = computed(() =>
|
||||
conversations.value.filter(c => c.id !== Number(props.conversationId))
|
||||
);
|
||||
|
||||
const activeContextChat = ref(null);
|
||||
const showContextMenu = ref(false);
|
||||
const contextMenu = ref({ x: null, y: null });
|
||||
|
||||
const buildConversationUrl = conversationId => {
|
||||
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';
|
||||
}
|
||||
|
||||
return frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
activeInbox: inboxId,
|
||||
id: conversationId,
|
||||
label,
|
||||
teamId,
|
||||
foldersId: isOnFoldersView({ route: { name } }) ? route.params.id : 0,
|
||||
conversationType,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const conversationPath = computed(() => {
|
||||
if (!activeContextChat.value) return '';
|
||||
return buildConversationUrl(activeContextChat.value.id);
|
||||
});
|
||||
|
||||
const onCardClick = (conversation, e) => {
|
||||
const path = buildConversationUrl(conversation.id);
|
||||
if (!path) return;
|
||||
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
window.open(
|
||||
`${window.chatwootConfig.hostURL}${path}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push({ path });
|
||||
};
|
||||
|
||||
const openContextMenu = (conversation, e) => {
|
||||
e.preventDefault();
|
||||
activeContextChat.value = conversation;
|
||||
contextMenu.value.x = e.pageX || e.clientX;
|
||||
contextMenu.value.y = e.pageY || e.clientY;
|
||||
showContextMenu.value = true;
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
showContextMenu.value = false;
|
||||
contextMenu.value.x = null;
|
||||
contextMenu.value.y = null;
|
||||
activeContextChat.value = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.contactId,
|
||||
(newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
showContextMenu.value = false;
|
||||
activeContextChat.value = null;
|
||||
store.dispatch('contactConversations/get', newId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('contactConversations/get', props.contactId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,18 +135,43 @@ export default {
|
||||
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="contact-conversation--list">
|
||||
<div
|
||||
v-else
|
||||
class="contact-conversation--list [&>.conversation:last-child]:!border-b-0 [&>.conversation:last-child:hover]:!border-b-0 [&>.conversation:last-child]:!rounded-b-lg"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in previousConversations"
|
||||
:key="conversation.id"
|
||||
:chat="conversation"
|
||||
:hide-inbox-name="false"
|
||||
:current-contact="contactGetter(conversation.meta?.sender?.id) || {}"
|
||||
:assignee="conversation.meta?.assignee || {}"
|
||||
:inbox="inboxGetter(conversation.inbox_id) || {}"
|
||||
:is-active-chat="currentChat.id === conversation.id"
|
||||
:show-inbox-name="showInboxName"
|
||||
hide-thumbnail
|
||||
enable-context-menu
|
||||
compact
|
||||
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
|
||||
@click="onCardClick(conversation, $event)"
|
||||
@contextmenu="openContextMenu(conversation, $event)"
|
||||
/>
|
||||
</div>
|
||||
<ContextMenu
|
||||
v-if="showContextMenu && activeContextChat"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
@close="closeContextMenu"
|
||||
>
|
||||
<ConversationContextMenu
|
||||
:status="activeContextChat.status"
|
||||
:inbox-id="activeContextChat.inbox_id"
|
||||
:priority="activeContextChat.priority"
|
||||
:chat-id="activeContextChat.id"
|
||||
:has-unread-messages="activeContextChat.unread_count > 0"
|
||||
:conversation-labels="activeContextChat.labels"
|
||||
:conversation-url="conversationPath"
|
||||
:allowed-options="['open-new-tab', 'copy-link']"
|
||||
@close="closeContextMenu"
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center py-5">
|
||||
<Spinner />
|
||||
|
||||
@@ -125,7 +125,7 @@ export default {
|
||||
set(priorityItem) {
|
||||
const conversationId = this.currentChat.id;
|
||||
const oldValue = this.currentChat?.priority;
|
||||
const priority = priorityItem ? priorityItem.id : null;
|
||||
const priority = priorityItem.id;
|
||||
|
||||
this.$store.dispatch('setCurrentChatPriority', {
|
||||
priority,
|
||||
@@ -203,7 +203,9 @@ export default {
|
||||
this.assignedPriority &&
|
||||
this.assignedPriority.id === selectedPriorityItem.id;
|
||||
|
||||
this.assignedPriority = isSamePriority ? null : selectedPriorityItem;
|
||||
this.assignedPriority = isSamePriority
|
||||
? this.priorityOptions[0]
|
||||
: selectedPriorityItem;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,19 +12,12 @@ import Avatar from 'next/avatar/Avatar.vue';
|
||||
import SocialIcons from './SocialIcons.vue';
|
||||
import EditContact from './EditContact.vue';
|
||||
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
|
||||
import ContactDeleteModal from 'dashboard/modules/contact/ContactDeleteModal.vue';
|
||||
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
getConversationDashboardRoute,
|
||||
} from '../../../../helper/routeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
@@ -34,6 +27,7 @@ export default {
|
||||
ComposeConversation,
|
||||
SocialIcons,
|
||||
ContactMergeModal,
|
||||
ContactDeleteModal,
|
||||
VoiceCallButton,
|
||||
InlineInput,
|
||||
},
|
||||
@@ -57,7 +51,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showEditModal: false,
|
||||
showDeleteModal: false,
|
||||
isEditingName: false,
|
||||
editName: '',
|
||||
};
|
||||
@@ -99,10 +92,6 @@ export default {
|
||||
telegram,
|
||||
};
|
||||
},
|
||||
// Delete Modal
|
||||
confirmDeleteMessage() {
|
||||
return ` ${this.contact.name}?`;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
'contact.id': {
|
||||
@@ -117,28 +106,6 @@ export default {
|
||||
toggleEditModal() {
|
||||
this.showEditModal = !this.showEditModal;
|
||||
},
|
||||
openComposeConversationModal(toggleFn) {
|
||||
toggleFn();
|
||||
// Flag to prevent triggering drag n drop,
|
||||
// When compose modal is active
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
|
||||
},
|
||||
closeComposeConversationModal() {
|
||||
// Flag to enable drag n drop,
|
||||
// When compose modal is closed
|
||||
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
|
||||
},
|
||||
toggleDeleteModal() {
|
||||
this.showDeleteModal = !this.showDeleteModal;
|
||||
},
|
||||
confirmDeletion() {
|
||||
this.deleteContact(this.contact);
|
||||
this.closeDelete();
|
||||
},
|
||||
closeDelete() {
|
||||
this.showDeleteModal = false;
|
||||
this.showEditModal = false;
|
||||
},
|
||||
findCountryFlag(countryCode, cityAndCountry) {
|
||||
try {
|
||||
if (!countryCode) {
|
||||
@@ -151,36 +118,6 @@ export default {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
async deleteContact({ id }) {
|
||||
try {
|
||||
await this.$store.dispatch('contacts/delete', id);
|
||||
this.$emit('panelClose');
|
||||
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
|
||||
|
||||
if (isAConversationRoute(this.$route.name)) {
|
||||
this.$router.push({
|
||||
name: getConversationDashboardRoute(this.$route.name),
|
||||
});
|
||||
} else if (isAInboxViewRoute(this.$route.name)) {
|
||||
this.$router.push({
|
||||
name: 'inbox_view',
|
||||
});
|
||||
} else if (this.$route.name !== 'contacts_dashboard') {
|
||||
this.$router.push({
|
||||
name: 'contacts_dashboard',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error.message
|
||||
? error.message
|
||||
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
},
|
||||
openMergeModal() {
|
||||
this.$refs.mergeModal?.open();
|
||||
},
|
||||
startEditingName() {
|
||||
this.editName = this.contact.name || '';
|
||||
this.isEditingName = true;
|
||||
@@ -354,19 +291,14 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full mt-0.5 gap-2">
|
||||
<ComposeConversation
|
||||
:contact-id="String(contact.id)"
|
||||
is-modal
|
||||
@close="closeComposeConversationModal"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<ComposeConversation :contact-id="String(contact.id)">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="openComposeConversationModal(toggle)"
|
||||
/>
|
||||
</template>
|
||||
</ComposeConversation>
|
||||
@@ -387,45 +319,41 @@ export default {
|
||||
sm
|
||||
@click="toggleEditModal"
|
||||
/>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
@click="openMergeModal"
|
||||
/>
|
||||
<NextButton
|
||||
<ContactMergeModal :primary-contact="contact">
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
|
||||
icon="i-ph-arrows-merge"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
:disabled="uiFlags.isMerging"
|
||||
/>
|
||||
</template>
|
||||
</ContactMergeModal>
|
||||
<ContactDeleteModal
|
||||
v-if="isAdmin"
|
||||
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
@click="toggleDeleteModal"
|
||||
/>
|
||||
:contact="contact"
|
||||
@deleted="$emit('panelClose')"
|
||||
>
|
||||
<template #trigger>
|
||||
<NextButton
|
||||
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
|
||||
icon="i-ph-trash"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
ruby
|
||||
:disabled="uiFlags.isDeleting"
|
||||
/>
|
||||
</template>
|
||||
</ContactDeleteModal>
|
||||
</div>
|
||||
<EditContact
|
||||
v-if="showEditModal"
|
||||
:show="showEditModal"
|
||||
:contact="contact"
|
||||
@cancel="toggleEditModal"
|
||||
/>
|
||||
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
|
||||
</div>
|
||||
<woot-delete-modal
|
||||
v-if="showDeleteModal"
|
||||
v-model:show="showDeleteModal"
|
||||
:on-close="closeDelete"
|
||||
:on-confirm="confirmDeletion"
|
||||
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
|
||||
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
|
||||
:message-value="confirmDeleteMessage"
|
||||
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
|
||||
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,74 +1,70 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import ContactForm from './ContactForm.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ContactForm,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contact: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
emits: ['cancel', 'update:show'],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'contacts/getUIFlags',
|
||||
}),
|
||||
localShow: {
|
||||
get() {
|
||||
return this.show;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('update:show', value);
|
||||
},
|
||||
},
|
||||
},
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contact: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
methods: {
|
||||
onCancel() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
onSuccess() {
|
||||
this.$emit('cancel');
|
||||
},
|
||||
async onSubmit(contactItem) {
|
||||
await this.$store.dispatch('contacts/update', contactItem);
|
||||
await this.$store.dispatch(
|
||||
'contacts/fetchContactableInbox',
|
||||
this.contact.id
|
||||
);
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const store = useStore();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const onCancel = () => emit('cancel');
|
||||
|
||||
const onSubmit = async contactItem => {
|
||||
await store.dispatch('contacts/update', contactItem);
|
||||
await store.dispatch('contacts/fetchContactableInbox', props.contact.id);
|
||||
};
|
||||
|
||||
// Restore Escape-to-close behavior that was provided by woot-modal before
|
||||
// this drawer was reimplemented as a plain fixed panel.
|
||||
useKeyboardEvents({
|
||||
Escape: {
|
||||
action: () => {
|
||||
if (props.show) onCancel();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal
|
||||
v-model:show="localShow"
|
||||
:on-close="onCancel"
|
||||
modal-type="right-aligned"
|
||||
<transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full opacity-0"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-to-class="ltr:translate-x-[30%] rtl:-translate-x-[30%] opacity-0"
|
||||
>
|
||||
<div class="flex flex-col h-auto overflow-auto">
|
||||
<woot-modal-header
|
||||
:header-title="`${$t('EDIT_CONTACT.TITLE')} - ${
|
||||
contact.name || contact.email
|
||||
}`"
|
||||
:header-content="$t('EDIT_CONTACT.DESC')"
|
||||
/>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-y-0 ltr:right-0 rtl:left-0 z-50 flex flex-col w-[30rem] max-w-full h-full bg-n-surface-2 ltr:border-l rtl:border-r border-n-weak shadow-lg overflow-auto"
|
||||
>
|
||||
<div class="flex items-center justify-between px-8 pt-8 pb-2">
|
||||
<div>
|
||||
<h2 class="text-lg font-medium text-n-slate-12 mb-1">
|
||||
{{
|
||||
`${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
|
||||
}}
|
||||
</h2>
|
||||
<p class="text-sm text-n-slate-11 mb-0">
|
||||
{{ $t('EDIT_CONTACT.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="i-lucide-x" slate ghost sm @click="onCancel" />
|
||||
</div>
|
||||
<ContactForm
|
||||
:contact="contact"
|
||||
:in-progress="uiFlags.isUpdating"
|
||||
:on-submit="onSubmit"
|
||||
@success="onSuccess"
|
||||
@success="onCancel"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
+1
@@ -119,6 +119,7 @@ watch(
|
||||
:is-category-articles="isCategoryArticles"
|
||||
@page-change="onPageChange"
|
||||
@fetch-portal="fetchPortalAndItsCategories"
|
||||
@refresh-articles="fetchArticles"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -112,9 +112,33 @@ export default {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
|
||||
uiFlags: 'inboxes/getUIFlags',
|
||||
portals: 'portals/allPortals',
|
||||
}),
|
||||
isInboundEmailEnabled() {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.INBOUND_EMAILS
|
||||
);
|
||||
},
|
||||
showContinuityToggle() {
|
||||
if (this.isInboundEmailEnabled) return true;
|
||||
return this.isOnChatwootCloud;
|
||||
},
|
||||
isContinuityDisabled() {
|
||||
return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
|
||||
},
|
||||
continuityDescription() {
|
||||
if (this.isContinuityDisabled) {
|
||||
return this.$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
|
||||
);
|
||||
}
|
||||
return this.$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
|
||||
);
|
||||
},
|
||||
selectedTabKey() {
|
||||
return this.tabs[this.selectedTabIndex]?.key;
|
||||
},
|
||||
@@ -542,7 +566,8 @@ export default {
|
||||
welcome_tagline: this.channelWelcomeTagline || '',
|
||||
selectedFeatureFlags: this.selectedFeatureFlags,
|
||||
reply_time: this.replyTime || 'in_a_few_minutes',
|
||||
continuity_via_email: this.continuityViaEmail,
|
||||
continuity_via_email:
|
||||
this.isInboundEmailEnabled && this.continuityViaEmail,
|
||||
},
|
||||
};
|
||||
if (this.avatarFile) {
|
||||
@@ -1148,15 +1173,15 @@ export default {
|
||||
/>
|
||||
|
||||
<SettingsToggleSection
|
||||
v-if="isAWebWidgetInbox"
|
||||
v-if="isAWebWidgetInbox && showContinuityToggle"
|
||||
v-model="continuityViaEmail"
|
||||
:header="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
|
||||
"
|
||||
:description="
|
||||
$t(
|
||||
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
|
||||
)
|
||||
:description="continuityDescription"
|
||||
:hide-toggle="isContinuityDisabled"
|
||||
:class="
|
||||
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
|
||||
"
|
||||
/>
|
||||
</SettingsAccordion>
|
||||
|
||||
@@ -166,4 +166,18 @@ export const actions = {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
bulkTranslate: async (
|
||||
_,
|
||||
{ portalSlug, articleIds, locale, categoryId, force = false }
|
||||
) => {
|
||||
const { data } = await articlesAPI.bulkTranslate({
|
||||
portalSlug,
|
||||
articleIds,
|
||||
locale,
|
||||
categoryId,
|
||||
force,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isApple } from './platform';
|
||||
|
||||
export const isEnter = e => {
|
||||
return e.key === 'Enter';
|
||||
};
|
||||
@@ -14,13 +16,20 @@ export const hasPressedCommand = e => {
|
||||
return e.metaKey;
|
||||
};
|
||||
|
||||
// True when the platform's "command" modifier is held: Cmd (metaKey) on
|
||||
// Apple platforms (macOS, iOS/iPadOS hardware keyboards), Ctrl (ctrlKey)
|
||||
// elsewhere. Mirrors the `$mod` convention used by tinykeys and
|
||||
// prosemirror-keymap so the editor and the app agree on what counts as the
|
||||
// send modifier.
|
||||
export const hasPressedMod = e => Boolean(isApple() ? e.metaKey : e.ctrlKey);
|
||||
|
||||
export const hasPressedEnterAndNotCmdOrShift = e => {
|
||||
return isEnter(e) && !hasPressedCommand(e) && !hasPressedShift(e);
|
||||
return isEnter(e) && !hasPressedMod(e) && !hasPressedShift(e);
|
||||
};
|
||||
|
||||
export const hasPressedCommandAndEnter = e => {
|
||||
return hasPressedCommand(e) && isEnter(e);
|
||||
};
|
||||
// Detects the platform-aware "send" shortcut: Cmd+Enter on Apple platforms,
|
||||
// Ctrl+Enter on Windows/Linux.
|
||||
export const hasPressedCommandAndEnter = e => hasPressedMod(e) && isEnter(e);
|
||||
|
||||
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Detects the current OS using the modern User-Agent Client Hints API,
|
||||
// falling back to userAgent parsing on Safari/Firefox where it is unavailable.
|
||||
// Treats iPad on iOS 13+ (which spoofs Macintosh) as iOS via maxTouchPoints.
|
||||
|
||||
export const OS = Object.freeze({
|
||||
MAC: 'macos',
|
||||
WINDOWS: 'windows',
|
||||
LINUX: 'linux',
|
||||
ANDROID: 'android',
|
||||
IOS: 'ios',
|
||||
UNKNOWN: 'unknown',
|
||||
});
|
||||
|
||||
// navigator.userAgentData.platform → OS constant (lowercased keys)
|
||||
const UAD_MAP = {
|
||||
macos: OS.MAC,
|
||||
windows: OS.WINDOWS,
|
||||
linux: OS.LINUX,
|
||||
android: OS.ANDROID,
|
||||
ios: OS.IOS,
|
||||
};
|
||||
|
||||
export function detectOS() {
|
||||
if (typeof navigator === 'undefined') return OS.UNKNOWN;
|
||||
|
||||
// Trust userAgentData only when it maps to a known OS; otherwise fall
|
||||
// through to UA parsing so unmapped values (e.g. "Chrome OS") don't leak.
|
||||
const uad = navigator.userAgentData?.platform?.toLowerCase();
|
||||
if (uad && UAD_MAP[uad]) return UAD_MAP[uad];
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/android/i.test(ua)) return OS.ANDROID;
|
||||
if (/iPhone|iPod/.test(ua)) return OS.IOS;
|
||||
if (
|
||||
/iPad/.test(ua) ||
|
||||
(/Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1)
|
||||
) {
|
||||
return OS.IOS;
|
||||
}
|
||||
if (/Win/i.test(ua)) return OS.WINDOWS;
|
||||
if (/Mac/i.test(ua)) return OS.MAC;
|
||||
if (/Linux/i.test(ua)) return OS.LINUX;
|
||||
|
||||
return OS.UNKNOWN;
|
||||
}
|
||||
|
||||
export const isApple = () => {
|
||||
const os = detectOS();
|
||||
return os === OS.MAC || os === OS.IOS;
|
||||
};
|
||||
@@ -3,9 +3,29 @@ import {
|
||||
isEscape,
|
||||
hasPressedShift,
|
||||
hasPressedCommand,
|
||||
hasPressedMod,
|
||||
hasPressedCommandAndEnter,
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
isActiveElementTypeable,
|
||||
} from '../KeyboardHelpers';
|
||||
|
||||
const setNavigator = navigatorValue => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: navigatorValue,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
const onMac = () => setNavigator({ userAgentData: { platform: 'macOS' } });
|
||||
const onWindows = () =>
|
||||
setNavigator({ userAgentData: { platform: 'Windows' } });
|
||||
const onIOS = () =>
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
|
||||
describe('#KeyboardHelpers', () => {
|
||||
describe('#isEnter', () => {
|
||||
it('return correct values', () => {
|
||||
@@ -30,6 +50,112 @@ describe('#KeyboardHelpers', () => {
|
||||
expect(hasPressedCommand({ metaKey: true })).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasPressedMod', () => {
|
||||
const originalNavigator = global.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(originalNavigator);
|
||||
});
|
||||
|
||||
it('uses metaKey on macOS', () => {
|
||||
onMac();
|
||||
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
|
||||
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses ctrlKey on Windows', () => {
|
||||
onWindows();
|
||||
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(true);
|
||||
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('uses metaKey on iOS hardware keyboards', () => {
|
||||
onIOS();
|
||||
expect(hasPressedMod({ metaKey: true, ctrlKey: false })).toBe(true);
|
||||
expect(hasPressedMod({ metaKey: false, ctrlKey: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no modifier is held', () => {
|
||||
onWindows();
|
||||
expect(hasPressedMod({ metaKey: false, ctrlKey: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasPressedCommandAndEnter', () => {
|
||||
const originalNavigator = global.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(originalNavigator);
|
||||
});
|
||||
|
||||
it('returns true for Cmd+Enter on macOS', () => {
|
||||
onMac();
|
||||
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for Ctrl+Enter on Windows (CW-6859 fix)', () => {
|
||||
onWindows();
|
||||
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for Ctrl+Enter on macOS (Mac uses Cmd, not Ctrl)', () => {
|
||||
onMac();
|
||||
expect(hasPressedCommandAndEnter({ key: 'Enter', ctrlKey: true })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('returns true for Cmd+Enter on iOS hardware keyboards', () => {
|
||||
onIOS();
|
||||
expect(hasPressedCommandAndEnter({ key: 'Enter', metaKey: true })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false for plain Enter', () => {
|
||||
onWindows();
|
||||
expect(hasPressedCommandAndEnter({ key: 'Enter' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#hasPressedEnterAndNotCmdOrShift', () => {
|
||||
const originalNavigator = global.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
setNavigator(originalNavigator);
|
||||
});
|
||||
|
||||
it('returns true for plain Enter on Windows', () => {
|
||||
onWindows();
|
||||
expect(hasPressedEnterAndNotCmdOrShift({ key: 'Enter' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for Ctrl+Enter on Windows (mod is held)', () => {
|
||||
onWindows();
|
||||
expect(
|
||||
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', ctrlKey: true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for Cmd+Enter on macOS (mod is held)', () => {
|
||||
onMac();
|
||||
expect(
|
||||
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', metaKey: true })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for Shift+Enter', () => {
|
||||
onWindows();
|
||||
expect(
|
||||
hasPressedEnterAndNotCmdOrShift({ key: 'Enter', shiftKey: true })
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActiveElementTypeable', () => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { detectOS, isApple, OS } from '../platform';
|
||||
|
||||
const setNavigator = ({ userAgentData, userAgent, maxTouchPoints } = {}) => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: { userAgentData, userAgent, maxTouchPoints },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
};
|
||||
|
||||
describe('detectOS', () => {
|
||||
const originalNavigator = global.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('with userAgentData available', () => {
|
||||
it('returns OS.MAC for macOS', () => {
|
||||
setNavigator({ userAgentData: { platform: 'macOS' } });
|
||||
expect(detectOS()).toBe(OS.MAC);
|
||||
});
|
||||
|
||||
it('returns OS.WINDOWS for Windows', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Windows' } });
|
||||
expect(detectOS()).toBe(OS.WINDOWS);
|
||||
});
|
||||
|
||||
it('returns OS.LINUX for Linux', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Linux' } });
|
||||
expect(detectOS()).toBe(OS.LINUX);
|
||||
});
|
||||
|
||||
it('returns OS.ANDROID for Android', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Android' } });
|
||||
expect(detectOS()).toBe(OS.ANDROID);
|
||||
});
|
||||
|
||||
it('falls through to userAgent for unmapped values like "Chrome OS"', () => {
|
||||
setNavigator({
|
||||
userAgentData: { platform: 'Chrome OS' },
|
||||
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64) AppleWebKit/537.36',
|
||||
});
|
||||
// Not a mapped UAD value AND not a recognized UA pattern → unknown
|
||||
expect(detectOS()).toBe(OS.UNKNOWN);
|
||||
});
|
||||
|
||||
it('prefers userAgentData over userAgent when value is mapped', () => {
|
||||
setNavigator({
|
||||
userAgentData: { platform: 'Windows' },
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.WINDOWS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with userAgent fallback', () => {
|
||||
it('detects macOS from Safari userAgent', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.MAC);
|
||||
});
|
||||
|
||||
it('detects Windows from userAgent', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.WINDOWS);
|
||||
});
|
||||
|
||||
it('detects Linux from userAgent', () => {
|
||||
setNavigator({
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.LINUX);
|
||||
});
|
||||
|
||||
it('detects Android from userAgent (before Linux match)', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.ANDROID);
|
||||
});
|
||||
|
||||
it('detects iOS from iPhone userAgent', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
expect(detectOS()).toBe(OS.IOS);
|
||||
});
|
||||
|
||||
it('detects iPadOS spoofing Macintosh via maxTouchPoints', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
maxTouchPoints: 5,
|
||||
});
|
||||
expect(detectOS()).toBe(OS.IOS);
|
||||
});
|
||||
|
||||
it('returns OS.UNKNOWN when no match', () => {
|
||||
setNavigator({ userAgent: 'SomeRandomBot/1.0' });
|
||||
expect(detectOS()).toBe(OS.UNKNOWN);
|
||||
});
|
||||
|
||||
it('returns OS.UNKNOWN when userAgent is missing', () => {
|
||||
setNavigator({});
|
||||
expect(detectOS()).toBe(OS.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('without navigator', () => {
|
||||
it('returns OS.UNKNOWN when navigator is undefined', () => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: undefined,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
expect(detectOS()).toBe(OS.UNKNOWN);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isApple', () => {
|
||||
const originalNavigator = global.navigator;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(global, 'navigator', {
|
||||
value: originalNavigator,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns true on macOS', () => {
|
||||
setNavigator({ userAgentData: { platform: 'macOS' } });
|
||||
expect(isApple()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true on iOS (iPhone)', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
});
|
||||
expect(isApple()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true on iPadOS spoofing Macintosh', () => {
|
||||
setNavigator({
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15',
|
||||
maxTouchPoints: 5,
|
||||
});
|
||||
expect(isApple()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on Windows', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Windows' } });
|
||||
expect(isApple()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on Linux', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Linux' } });
|
||||
expect(isApple()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on Android', () => {
|
||||
setNavigator({ userAgentData: { platform: 'Android' } });
|
||||
expect(isApple()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OS constants', () => {
|
||||
it('is frozen so callers cannot mutate it', () => {
|
||||
expect(Object.isFrozen(OS)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,153 +1,153 @@
|
||||
{
|
||||
"COMPONENTS": {
|
||||
"FILE_BUBBLE": {
|
||||
"DOWNLOAD": "Download",
|
||||
"UPLOADING": "Uploading..."
|
||||
"DOWNLOAD": "Laadi alla",
|
||||
"UPLOADING": "Üleslaadimine..."
|
||||
},
|
||||
"FORM_BUBBLE": {
|
||||
"SUBMIT": "Submit"
|
||||
"SUBMIT": "Saada"
|
||||
},
|
||||
"MESSAGE_BUBBLE": {
|
||||
"RETRY": "Send message again",
|
||||
"ERROR_MESSAGE": "Couldn't send, try again"
|
||||
"RETRY": "Saada sõnum uuesti",
|
||||
"ERROR_MESSAGE": "Saatmine ebaõnnestus, proovi uuesti"
|
||||
}
|
||||
},
|
||||
"THUMBNAIL": {
|
||||
"AUTHOR": {
|
||||
"NOT_AVAILABLE": "Not available"
|
||||
"NOT_AVAILABLE": "Pole saadaval"
|
||||
}
|
||||
},
|
||||
"TEAM_AVAILABILITY": {
|
||||
"ONLINE": "We are online",
|
||||
"OFFLINE": "We are away at the moment",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "We will be back as soon as possible"
|
||||
"ONLINE": "Oleme võrgus",
|
||||
"OFFLINE": "Oleme hetkel eemal",
|
||||
"BACK_AS_SOON_AS_POSSIBLE": "Oleme tagasi esimesel võimalusel"
|
||||
},
|
||||
"REPLY_TIME": {
|
||||
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
|
||||
"IN_A_FEW_HOURS": "Typically replies in a few hours",
|
||||
"IN_A_DAY": "Typically replies in a day",
|
||||
"BACK_IN_HOURS": "We will be back online in {n} hour | We will be back online in {n} hours",
|
||||
"BACK_IN_MINUTES": "We will be back online in {time} minutes",
|
||||
"BACK_AT_TIME": "We will be back online at {time}",
|
||||
"BACK_ON_DAY": "We will be back online on {day}",
|
||||
"BACK_TOMORROW": "We will be back online tomorrow",
|
||||
"BACK_IN_SOME_TIME": "We will be back online in some time"
|
||||
"IN_A_FEW_MINUTES": "Tavaliselt vastame mõne minuti jooksul",
|
||||
"IN_A_FEW_HOURS": "Tavaliselt vastame mõne tunni jooksul",
|
||||
"IN_A_DAY": "Tavaliselt vastame päeva jooksul",
|
||||
"BACK_IN_HOURS": "Oleme tagasi {n} tunni pärast | Oleme tagasi {n} tunni pärast",
|
||||
"BACK_IN_MINUTES": "Oleme tagasi {time} minuti pärast",
|
||||
"BACK_AT_TIME": "Oleme tagasi kell {time}",
|
||||
"BACK_ON_DAY": "Oleme tagasi {day}",
|
||||
"BACK_TOMORROW": "Oleme tagasi homme",
|
||||
"BACK_IN_SOME_TIME": "Oleme mõne aja pärast tagasi"
|
||||
},
|
||||
"DAY_NAMES": {
|
||||
"SUNDAY": "Sunday",
|
||||
"MONDAY": "Monday",
|
||||
"TUESDAY": "Tuesday",
|
||||
"WEDNESDAY": "Wednesday",
|
||||
"THURSDAY": "Thursday",
|
||||
"FRIDAY": "Friday",
|
||||
"SATURDAY": "Saturday"
|
||||
"SUNDAY": "Pühapäev",
|
||||
"MONDAY": "Esmaspäev",
|
||||
"TUESDAY": "Teisipäev",
|
||||
"WEDNESDAY": "Kolmapäev",
|
||||
"THURSDAY": "Neljapäev",
|
||||
"FRIDAY": "Reede",
|
||||
"SATURDAY": "Laupäev"
|
||||
},
|
||||
"START_CONVERSATION": "Start Conversation",
|
||||
"END_CONVERSATION": "End Conversation",
|
||||
"CONTINUE_CONVERSATION": "Continue conversation",
|
||||
"YOU": "You",
|
||||
"START_NEW_CONVERSATION": "Start a new conversation",
|
||||
"VIEW_UNREAD_MESSAGES": "You have unread messages",
|
||||
"START_CONVERSATION": "Alusta vestlust",
|
||||
"END_CONVERSATION": "Lõpeta vestlus",
|
||||
"CONTINUE_CONVERSATION": "Jätka vestlust",
|
||||
"YOU": "Sina",
|
||||
"START_NEW_CONVERSATION": "Alusta uut vestlust",
|
||||
"VIEW_UNREAD_MESSAGES": "Sul on lugemata sõnumeid",
|
||||
"UNREAD_VIEW": {
|
||||
"VIEW_MESSAGES_BUTTON": "See new messages",
|
||||
"CLOSE_MESSAGES_BUTTON": "Close",
|
||||
"COMPANY_FROM": "from",
|
||||
"VIEW_MESSAGES_BUTTON": "Vaata uusi sõnumeid",
|
||||
"CLOSE_MESSAGES_BUTTON": "Sulge",
|
||||
"COMPANY_FROM": "saatjalt",
|
||||
"BOT": "Bot"
|
||||
},
|
||||
"BUBBLE": {
|
||||
"LABEL": "Chat with us"
|
||||
"LABEL": "Vestle meiega"
|
||||
},
|
||||
"POWERED_BY": "Powered by Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Please enter your email",
|
||||
"CHAT_PLACEHOLDER": "Type your message",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"POWERED_BY": "Toetab Chatwoot",
|
||||
"EMAIL_PLACEHOLDER": "Palun sisesta oma e-post",
|
||||
"CHAT_PLACEHOLDER": "Kirjuta oma sõnum",
|
||||
"TODAY": "Täna",
|
||||
"YESTERDAY": "Eile",
|
||||
"PRE_CHAT_FORM": {
|
||||
"FIELDS": {
|
||||
"FULL_NAME": {
|
||||
"LABEL": "Full Name",
|
||||
"PLACEHOLDER": "Please enter your full name",
|
||||
"REQUIRED_ERROR": "Full Name is required"
|
||||
"LABEL": "Täisnimi",
|
||||
"PLACEHOLDER": "Palun sisesta oma täisnimi",
|
||||
"REQUIRED_ERROR": "Täisnimi on kohustuslik"
|
||||
},
|
||||
"EMAIL_ADDRESS": {
|
||||
"LABEL": "Email Address",
|
||||
"PLACEHOLDER": "Please enter your email address",
|
||||
"REQUIRED_ERROR": "Email Address is required",
|
||||
"VALID_ERROR": "Please enter a valid email address"
|
||||
"LABEL": "E-posti aadress",
|
||||
"PLACEHOLDER": "Palun sisesta oma e-posti aadress",
|
||||
"REQUIRED_ERROR": "E-posti aadress on kohustuslik",
|
||||
"VALID_ERROR": "Palun sisesta kehtiv e-posti aadress"
|
||||
},
|
||||
"PHONE_NUMBER": {
|
||||
"LABEL": "Phone Number",
|
||||
"PLACEHOLDER": "Please enter your phone number",
|
||||
"REQUIRED_ERROR": "Phone Number is required",
|
||||
"DIAL_CODE_VALID_ERROR": "Please select a country code",
|
||||
"VALID_ERROR": "Please enter a valid phone number",
|
||||
"DROPDOWN_EMPTY": "No results found",
|
||||
"DROPDOWN_SEARCH": "Search country"
|
||||
"LABEL": "Telefoninumber",
|
||||
"PLACEHOLDER": "Palun sisesta oma telefoninumber",
|
||||
"REQUIRED_ERROR": "Telefoninumber on kohustuslik",
|
||||
"DIAL_CODE_VALID_ERROR": "Palun vali riigikood",
|
||||
"VALID_ERROR": "Palun sisesta kehtiv telefoninumber",
|
||||
"DROPDOWN_EMPTY": "Tulemusi ei leitud",
|
||||
"DROPDOWN_SEARCH": "Otsi riiki"
|
||||
},
|
||||
"MESSAGE": {
|
||||
"LABEL": "Message",
|
||||
"PLACEHOLDER": "Please enter your message",
|
||||
"ERROR": "Message too short"
|
||||
"LABEL": "Sõnum",
|
||||
"PLACEHOLDER": "Palun sisesta oma sõnum",
|
||||
"ERROR": "Sõnum on liiga lühike"
|
||||
}
|
||||
},
|
||||
"CAMPAIGN_HEADER": "Please provide your name and email before starting the conversation",
|
||||
"IS_REQUIRED": "is required",
|
||||
"REQUIRED": "Required",
|
||||
"REGEX_ERROR": "Please provide a valid input"
|
||||
"CAMPAIGN_HEADER": "Palun sisesta enne vestluse alustamist oma nimi ja e-post",
|
||||
"IS_REQUIRED": "on kohustuslik",
|
||||
"REQUIRED": "Kohustuslik",
|
||||
"REGEX_ERROR": "Palun sisesta korrektne väärtus"
|
||||
},
|
||||
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
|
||||
"FILE_SIZE_LIMIT": "Fail ületab {MAXIMUM_FILE_UPLOAD_SIZE} manuse limiidi",
|
||||
"CHAT_FORM": {
|
||||
"INVALID": {
|
||||
"FIELD": "Invalid field"
|
||||
"FIELD": "Vigane väli"
|
||||
}
|
||||
},
|
||||
"EMOJI": {
|
||||
"PLACEHOLDER": "Search emojis",
|
||||
"NOT_FOUND": "No emoji match your search",
|
||||
"ARIA_LABEL": "Emoji picker"
|
||||
"PLACEHOLDER": "Otsi emotikone",
|
||||
"NOT_FOUND": "Ühtegi emotikoni ei leitud",
|
||||
"ARIA_LABEL": "Emotikonide valija"
|
||||
},
|
||||
"CSAT": {
|
||||
"TITLE": "Rate your conversation",
|
||||
"SUBMITTED_TITLE": "Thank you for submitting the rating",
|
||||
"PLACEHOLDER": "Tell us more..."
|
||||
"TITLE": "Hinda oma vestlust",
|
||||
"SUBMITTED_TITLE": "Täname hinnangu eest",
|
||||
"PLACEHOLDER": "Räägi meile rohkem..."
|
||||
},
|
||||
"EMAIL_TRANSCRIPT": {
|
||||
"BUTTON_TEXT": "Request a conversation transcript",
|
||||
"SEND_EMAIL_SUCCESS": "The chat transcript was sent successfully",
|
||||
"SEND_EMAIL_ERROR": "There was an error, please try again"
|
||||
"BUTTON_TEXT": "Taotle vestluse koopiat",
|
||||
"SEND_EMAIL_SUCCESS": "Vestluse koopia saadeti edukalt",
|
||||
"SEND_EMAIL_ERROR": "Tekkis viga, palun proovi uuesti"
|
||||
},
|
||||
"INTEGRATIONS": {
|
||||
"DYTE": {
|
||||
"CLICK_HERE_TO_JOIN": "Click here to join",
|
||||
"LEAVE_THE_ROOM": "Leave the call"
|
||||
"CLICK_HERE_TO_JOIN": "Klõpsa siia liitumiseks",
|
||||
"LEAVE_THE_ROOM": "Lahku kõnest"
|
||||
}
|
||||
},
|
||||
"PORTAL": {
|
||||
"POPULAR_ARTICLES": "Popular Articles",
|
||||
"VIEW_ALL_ARTICLES": "View all articles",
|
||||
"IFRAME_LOAD_ERROR": "There was an error loading the article, please refresh the page and try again."
|
||||
"POPULAR_ARTICLES": "Populaarsed artiklid",
|
||||
"VIEW_ALL_ARTICLES": "Vaata kõiki artikleid",
|
||||
"IFRAME_LOAD_ERROR": "Artikli laadimisel tekkis viga, palun värskenda lehte ja proovi uuesti."
|
||||
},
|
||||
"ATTACHMENTS": {
|
||||
"image": {
|
||||
"CONTENT": "Picture message"
|
||||
"CONTENT": "Pildisõnum"
|
||||
},
|
||||
"audio": {
|
||||
"CONTENT": "Audio message"
|
||||
"CONTENT": "Helisõnum"
|
||||
},
|
||||
"video": {
|
||||
"CONTENT": "Video message"
|
||||
"CONTENT": "Videosõnum"
|
||||
},
|
||||
"file": {
|
||||
"CONTENT": "File Attachment"
|
||||
"CONTENT": "Faili manus"
|
||||
},
|
||||
"location": {
|
||||
"CONTENT": "Location"
|
||||
"CONTENT": "Asukoht"
|
||||
},
|
||||
"fallback": {
|
||||
"CONTENT": "has shared a url"
|
||||
"CONTENT": "jagas URL-i"
|
||||
}
|
||||
},
|
||||
"FOOTER_REPLY_TO": {
|
||||
"REPLY_TO": "Replying to:"
|
||||
"REPLY_TO": "Vastus sõnumile:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,8 +42,13 @@ class Account::ContactsExportJob < ApplicationJob
|
||||
def attach_export_file(csv_data)
|
||||
return if csv_data.blank?
|
||||
|
||||
# Prepend UTF-8 BOM so that spreadsheet applications (e.g. Excel)
|
||||
# correctly recognise the file encoding for non-ASCII characters
|
||||
# such as Arabic, Japanese, and Chinese.
|
||||
bom = "\xEF\xBB\xBF"
|
||||
|
||||
@account.contacts_export.attach(
|
||||
io: StringIO.new(csv_data),
|
||||
io: StringIO.new("#{bom}#{csv_data}"),
|
||||
filename: "#{@account.name}_#{@account.id}_contacts.csv",
|
||||
content_type: 'text/csv'
|
||||
)
|
||||
|
||||
@@ -9,27 +9,17 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
include UrlHelper
|
||||
queue_as :purgable
|
||||
|
||||
MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
|
||||
ALLOWED_CONTENT_TYPES = Avatarable::ALLOWED_AVATAR_CONTENT_TYPES
|
||||
MAX_DOWNLOAD_SIZE = 15.megabytes
|
||||
RATE_LIMIT_WINDOW = 1.minute
|
||||
|
||||
def perform(avatarable, avatar_url)
|
||||
return unless avatarable.respond_to?(:avatar)
|
||||
return unless url_valid?(avatar_url)
|
||||
return unless syncable_avatar?(avatarable, avatar_url)
|
||||
|
||||
return unless should_sync_avatar?(avatarable, avatar_url)
|
||||
|
||||
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
|
||||
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
|
||||
|
||||
avatarable.avatar.attach(
|
||||
io: avatar_file,
|
||||
filename: avatar_file.original_filename,
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
|
||||
rescue Down::NotFound
|
||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||
rescue Down::Error => e
|
||||
fetch_and_attach_avatar(avatarable, avatar_url)
|
||||
rescue SafeFetch::HttpError => e
|
||||
log_http_error(avatar_url, e)
|
||||
rescue SafeFetch::Error => e
|
||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||
ensure
|
||||
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||
@@ -37,6 +27,41 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def syncable_avatar?(avatarable, avatar_url)
|
||||
avatarable.respond_to?(:avatar) &&
|
||||
url_valid?(avatar_url) &&
|
||||
should_sync_avatar?(avatarable, avatar_url)
|
||||
end
|
||||
|
||||
def fetch_and_attach_avatar(avatarable, avatar_url)
|
||||
SafeFetch.fetch(
|
||||
avatar_url,
|
||||
max_bytes: MAX_DOWNLOAD_SIZE,
|
||||
allowed_content_type_prefixes: [],
|
||||
allowed_content_types: ALLOWED_CONTENT_TYPES
|
||||
) do |avatar_file|
|
||||
attach_avatar(avatarable, avatar_file)
|
||||
end
|
||||
end
|
||||
|
||||
def attach_avatar(avatarable, avatar_file)
|
||||
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
|
||||
|
||||
avatarable.avatar.attach(
|
||||
io: avatar_file.tempfile,
|
||||
filename: avatar_file.original_filename,
|
||||
content_type: avatar_file.content_type
|
||||
)
|
||||
end
|
||||
|
||||
def log_http_error(avatar_url, error)
|
||||
if error.message.start_with?('404')
|
||||
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
|
||||
else
|
||||
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def should_sync_avatar?(avatarable, avatar_url)
|
||||
# Only Contacts are rate-limited and hash-gated.
|
||||
return true unless avatarable.is_a?(Contact)
|
||||
|
||||
@@ -106,6 +106,7 @@ class DataImportJob < ApplicationJob
|
||||
raw_data = file.read
|
||||
utf8_data = raw_data.force_encoding('UTF-8')
|
||||
clean_data = utf8_data.valid_encoding? ? utf8_data : utf8_data.encode('UTF-16le', invalid: :replace, replace: '').encode('UTF-8')
|
||||
clean_data = clean_data.delete_prefix("\xEF\xBB\xBF")
|
||||
|
||||
CSV.new(StringIO.new(clean_data), headers: true)
|
||||
end
|
||||
|
||||
@@ -105,7 +105,7 @@ class ConversationReplyMailer < ApplicationMailer
|
||||
end
|
||||
|
||||
def business_name
|
||||
@inbox.business_name || @inbox.sanitized_name
|
||||
@inbox.sanitized_business_name
|
||||
end
|
||||
|
||||
def from_email
|
||||
|
||||
@@ -4,6 +4,8 @@ module Avatarable
|
||||
extend ActiveSupport::Concern
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
ALLOWED_AVATAR_CONTENT_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
|
||||
|
||||
included do
|
||||
has_one_attached :avatar
|
||||
validate :acceptable_avatar, if: -> { avatar.changed? }
|
||||
@@ -30,7 +32,6 @@ module Avatarable
|
||||
|
||||
errors.add(:avatar, 'is too big') if avatar.byte_size > 15.megabytes
|
||||
|
||||
acceptable_types = ['image/jpeg', 'image/png', 'image/gif'].freeze
|
||||
errors.add(:avatar, 'filetype not supported') unless acceptable_types.include?(avatar.content_type)
|
||||
errors.add(:avatar, 'filetype not supported') unless ALLOWED_AVATAR_CONTENT_TYPES.include?(avatar.content_type)
|
||||
end
|
||||
end
|
||||
|
||||
+13
-2
@@ -102,7 +102,7 @@ class Inbox < ApplicationRecord
|
||||
|
||||
# Sanitizes inbox name for balanced email provider compatibility
|
||||
# ALLOWS: /'._- and Unicode letters/numbers/emojis
|
||||
# REMOVES: Forbidden chars (\<>@") + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
# REMOVES: Forbidden chars (\<>@"()) + spam-trigger symbols (!#$%&*+=?^`{|}~)
|
||||
def sanitized_name
|
||||
return default_name_for_blank_name if name.blank?
|
||||
|
||||
@@ -110,6 +110,10 @@ class Inbox < ApplicationRecord
|
||||
sanitized.blank? && email? ? display_name_from_email : sanitized
|
||||
end
|
||||
|
||||
def sanitized_business_name
|
||||
sanitize_raw_name(business_name) || sanitized_name
|
||||
end
|
||||
|
||||
def sms?
|
||||
channel_type == 'Channel::Sms'
|
||||
end
|
||||
@@ -209,8 +213,15 @@ class Inbox < ApplicationRecord
|
||||
email? ? display_name_from_email : ''
|
||||
end
|
||||
|
||||
def sanitize_raw_name(raw)
|
||||
return nil if raw.blank?
|
||||
|
||||
result = apply_sanitization_rules(raw)
|
||||
result.presence
|
||||
end
|
||||
|
||||
def apply_sanitization_rules(name)
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;]/, '') # Remove forbidden chars
|
||||
name.gsub(/[\\<>@"!#$%&*+=?^`{|}~:;()]/, '') # Remove forbidden chars
|
||||
.gsub(/[\x00-\x1F\x7F]/, ' ') # Replace control chars with spaces
|
||||
.gsub(/\A[[:punct:]]+|[[:punct:]]+\z/, '') # Remove leading/trailing punctuation
|
||||
.gsub(/\s+/, ' ') # Normalize spaces
|
||||
|
||||
@@ -39,7 +39,8 @@ class Messages::SearchDataPresenter < SimpleDelegator
|
||||
end
|
||||
|
||||
def content_attributes_data
|
||||
email_subject = content_attributes.dig(:email, :subject)
|
||||
email_subject = content_attributes.dig(:email, :subject).presence ||
|
||||
conversation.additional_attributes&.dig('mail_subject').presence
|
||||
return {} if email_subject.blank?
|
||||
|
||||
{ email: { subject: email_subject } }
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
class Notification::PushTestService
|
||||
pattr_initialize [:user!, :subscription_ids!, :title, :body]
|
||||
|
||||
DEFAULT_TITLE = '%<installation_name>s notification test'.freeze
|
||||
DEFAULT_BODY = 'This is a test from our team to check notification delivery on your device. No action needed.'.freeze
|
||||
|
||||
def self.default_title
|
||||
format(DEFAULT_TITLE, installation_name: GlobalConfigService.load('INSTALLATION_NAME', 'Chatwoot'))
|
||||
end
|
||||
|
||||
def self.default_body
|
||||
DEFAULT_BODY
|
||||
end
|
||||
|
||||
def perform
|
||||
selected_subscriptions.map { |subscription| test_send(subscription) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolved_title
|
||||
title.presence || self.class.default_title
|
||||
end
|
||||
|
||||
def resolved_body
|
||||
body.presence || self.class.default_body
|
||||
end
|
||||
|
||||
def selected_subscriptions
|
||||
user.notification_subscriptions.where(id: subscription_ids).order(:id)
|
||||
end
|
||||
|
||||
def test_send(subscription)
|
||||
if subscription.browser_push?
|
||||
test_browser_push(subscription)
|
||||
elsif subscription.fcm?
|
||||
test_fcm(subscription)
|
||||
else
|
||||
result(subscription, subscription.subscription_type.to_s, :skipped, 'Unknown subscription type')
|
||||
end
|
||||
end
|
||||
|
||||
def test_browser_push(subscription)
|
||||
return result(subscription, 'browser_push', :skipped, 'VAPID keys not configured') unless VapidService.public_key
|
||||
|
||||
WebPush.payload_send(**browser_push_payload(subscription))
|
||||
result(subscription, 'browser_push', :success, 'Web push accepted by endpoint')
|
||||
rescue StandardError => e
|
||||
result(subscription, 'browser_push', :failure, "#{e.class.name}: #{e.message}")
|
||||
end
|
||||
|
||||
def test_fcm(subscription)
|
||||
if firebase_credentials_present?
|
||||
test_fcm_direct(subscription)
|
||||
elsif chatwoot_hub_enabled?
|
||||
test_fcm_via_hub(subscription)
|
||||
else
|
||||
result(subscription, 'fcm', :skipped, 'No Firebase credentials and push relay disabled')
|
||||
end
|
||||
end
|
||||
|
||||
def test_fcm_direct(subscription)
|
||||
fcm_service = Notification::FcmService.new(
|
||||
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil),
|
||||
GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
|
||||
)
|
||||
response = fcm_service.fcm_client.send_v1(fcm_options(subscription))
|
||||
status_code = response[:status_code].to_i
|
||||
status = status_code.between?(200, 299) ? :success : :failure
|
||||
result(subscription, 'fcm', status, "HTTP #{status_code} — #{response[:body]}")
|
||||
rescue StandardError => e
|
||||
result(subscription, 'fcm', :failure, "#{e.class.name}: #{e.message}")
|
||||
end
|
||||
|
||||
def test_fcm_via_hub(subscription)
|
||||
response = ChatwootHub.send_push_with_response(fcm_options(subscription))
|
||||
result(subscription, 'fcm_via_hub', :success, "HTTP #{response.code} — #{response.body}")
|
||||
rescue RestClient::ExceptionWithResponse => e
|
||||
result(subscription, 'fcm_via_hub', :failure, "HTTP #{e.response&.code} — #{e.response&.body}")
|
||||
rescue StandardError => e
|
||||
result(subscription, 'fcm_via_hub', :failure, "#{e.class.name}: #{e.message}")
|
||||
end
|
||||
|
||||
def firebase_credentials_present?
|
||||
GlobalConfigService.load('FIREBASE_PROJECT_ID', nil) && GlobalConfigService.load('FIREBASE_CREDENTIALS', nil)
|
||||
end
|
||||
|
||||
def chatwoot_hub_enabled?
|
||||
ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_PUSH_RELAY_SERVER', true))
|
||||
end
|
||||
|
||||
def browser_push_payload(subscription)
|
||||
{
|
||||
message: JSON.generate(
|
||||
title: resolved_title,
|
||||
tag: "super_admin_test_#{Time.zone.now.to_i}",
|
||||
url: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com')
|
||||
),
|
||||
endpoint: subscription.subscription_attributes['endpoint'],
|
||||
p256dh: subscription.subscription_attributes['p256dh'],
|
||||
auth: subscription.subscription_attributes['auth'],
|
||||
vapid: {
|
||||
subject: ENV.fetch('FRONTEND_URL', 'https://app.chatwoot.com'),
|
||||
public_key: VapidService.public_key,
|
||||
private_key: VapidService.private_key
|
||||
},
|
||||
ssl_timeout: 5,
|
||||
open_timeout: 5,
|
||||
read_timeout: 5
|
||||
}
|
||||
end
|
||||
|
||||
def fcm_options(subscription)
|
||||
{
|
||||
'token': subscription.subscription_attributes['push_token'],
|
||||
'data': { payload: { data: { notification: { type: 'test' } } }.to_json },
|
||||
'notification': { title: resolved_title, body: resolved_body },
|
||||
'android': { priority: 'high' },
|
||||
'apns': { payload: { aps: { sound: 'default', category: Time.zone.now.to_i.to_s } } },
|
||||
'fcm_options': { analytics_label: 'SuperAdminTest' }
|
||||
}
|
||||
end
|
||||
|
||||
def result(subscription, type, status, message)
|
||||
attrs = subscription.subscription_attributes || {}
|
||||
{
|
||||
id: subscription.id,
|
||||
type: type.to_s,
|
||||
device: device_label(subscription, attrs),
|
||||
token_tail: token_tail(subscription, attrs),
|
||||
status: status,
|
||||
message: message
|
||||
}
|
||||
end
|
||||
|
||||
def device_label(subscription, attrs)
|
||||
if subscription.browser_push?
|
||||
endpoint_host(attrs['endpoint'].to_s)
|
||||
else
|
||||
attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—'
|
||||
end
|
||||
end
|
||||
|
||||
def endpoint_host(endpoint)
|
||||
return '—' if endpoint.blank?
|
||||
|
||||
URI.parse(endpoint).host.presence || endpoint
|
||||
rescue URI::InvalidURIError
|
||||
endpoint
|
||||
end
|
||||
|
||||
def token_tail(subscription, attrs)
|
||||
if subscription.browser_push?
|
||||
endpoint = attrs['endpoint'].to_s
|
||||
endpoint.present? ? "…#{endpoint.last(6)}" : '—'
|
||||
else
|
||||
attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
class Reports::DataSource
|
||||
include TimezoneHelper
|
||||
|
||||
attr_reader :account, :metric, :dimension_type, :dimension_id,
|
||||
:scope, :range, :group_by, :timezone_offset,
|
||||
:business_hours
|
||||
|
||||
class << self
|
||||
def for(**context)
|
||||
# TODO: Route to Reports::RollupDataSource when rollup reads are implemented
|
||||
Reports::RawDataSource.new(**context)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(**context)
|
||||
@account = context[:account]
|
||||
@metric = context[:metric]
|
||||
@dimension_type = (context[:dimension_type].presence || 'account').to_s
|
||||
@dimension_id = context[:dimension_id]
|
||||
@scope = context[:scope]
|
||||
@range = context[:range]
|
||||
@group_by = context[:group_by].to_s.presence || 'day'
|
||||
@timezone_offset = context[:timezone_offset]
|
||||
@business_hours = context[:business_hours]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def report_metric
|
||||
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||
end
|
||||
|
||||
def average_metric?
|
||||
report_metric&.average?
|
||||
end
|
||||
|
||||
def count_metric?
|
||||
!average_metric?
|
||||
end
|
||||
|
||||
def rollup_metric
|
||||
report_metric&.rollup_metric
|
||||
end
|
||||
|
||||
def raw_event_name
|
||||
report_metric&.raw_event_name
|
||||
end
|
||||
|
||||
def raw_count_strategy
|
||||
report_metric&.raw_count_strategy
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
@summary_metrics ||= Reports::ReportMetricRegistry.summary_metrics
|
||||
end
|
||||
|
||||
def timezone
|
||||
@timezone ||= timezone_name_from_offset(timezone_offset)
|
||||
end
|
||||
|
||||
def use_business_hours?
|
||||
ActiveModel::Type::Boolean.new.cast(business_hours)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
class Reports::RawDataSource < Reports::DataSource
|
||||
def timeseries
|
||||
average_metric? ? average_timeseries : count_timeseries
|
||||
end
|
||||
|
||||
def aggregate
|
||||
average_metric? ? average_scope.average(average_value_key) : count_scope.count
|
||||
end
|
||||
|
||||
def summary
|
||||
metric_results = summary_scope
|
||||
.select(*summary_select_fields)
|
||||
.group(summary_group_by_key)
|
||||
.index_by { |record| record.public_send(summary_index_key) }
|
||||
|
||||
merge_summary_results(metric_results, summary_conversation_counts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def count_timeseries
|
||||
grouped_count.map do |event_date, event_count|
|
||||
{ value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
|
||||
end
|
||||
end
|
||||
|
||||
def average_timeseries
|
||||
grouped_average_time = grouped_average_scope.average(average_value_key)
|
||||
grouped_event_count = grouped_average_scope.count
|
||||
|
||||
grouped_average_time.each_with_object([]) do |(event_date, average_time), results|
|
||||
results << {
|
||||
value: average_time,
|
||||
timestamp: event_date.in_time_zone(timezone).to_i,
|
||||
count: grouped_event_count[event_date]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def grouped_average_scope
|
||||
average_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
)
|
||||
end
|
||||
|
||||
def grouped_count
|
||||
count_scope.group_by_period(
|
||||
group_by,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
range: range,
|
||||
permit: %w[day week month year hour],
|
||||
time_zone: timezone
|
||||
).count
|
||||
end
|
||||
|
||||
def average_scope
|
||||
scope.reporting_events.where(name: raw_event_name, created_at: range, account_id: account.id)
|
||||
end
|
||||
|
||||
def count_scope
|
||||
case metric.to_s
|
||||
when 'conversations_count'
|
||||
scope.conversations.where(account_id: account.id, created_at: range)
|
||||
when 'incoming_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
|
||||
when 'outgoing_messages_count'
|
||||
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
|
||||
else
|
||||
reporting_event_count_scope
|
||||
end
|
||||
end
|
||||
|
||||
def reporting_event_count_scope
|
||||
events = scope.reporting_events.where(
|
||||
name: raw_event_name,
|
||||
account_id: account.id,
|
||||
created_at: range
|
||||
)
|
||||
|
||||
return events unless raw_count_strategy == :distinct_conversation
|
||||
|
||||
events.joins(:conversation).select(:conversation_id).distinct
|
||||
end
|
||||
|
||||
def summary_scope
|
||||
scope = account.reporting_events.where(created_at: range)
|
||||
return scope.joins(:conversation) if dimension_type == 'team'
|
||||
|
||||
scope
|
||||
end
|
||||
|
||||
def summary_conversation_counts
|
||||
account.conversations
|
||||
.where(created_at: range)
|
||||
.group(summary_conversation_group_by_key)
|
||||
.count
|
||||
end
|
||||
|
||||
def merge_summary_results(metric_results, conversation_counts)
|
||||
(metric_results.keys | conversation_counts.keys).each_with_object({}) do |dimension_id, results|
|
||||
record = metric_results[dimension_id]
|
||||
results[dimension_id] = summary_attributes_for(record, conversation_counts[dimension_id])
|
||||
end
|
||||
end
|
||||
|
||||
def summary_select_fields
|
||||
["#{summary_group_by_key} as #{summary_index_key}"] + summary_metrics.map { |definition| summary_select_field(definition) }
|
||||
end
|
||||
|
||||
def summary_select_field(definition)
|
||||
if definition.count?
|
||||
"COUNT(CASE WHEN name = '#{definition.raw_event_name}' THEN 1 END) as #{definition.summary_key}"
|
||||
else
|
||||
"AVG(CASE WHEN name = '#{definition.raw_event_name}' THEN #{average_value_key} END) as #{definition.summary_key}"
|
||||
end
|
||||
end
|
||||
|
||||
def summary_attributes_for(record, conversations_count = 0)
|
||||
summary_metrics.each_with_object({ conversations_count: conversations_count.to_i }) do |definition, attributes|
|
||||
value = record&.public_send(definition.summary_key)
|
||||
attributes[definition.summary_key] = definition.count? ? value.to_i : value
|
||||
end
|
||||
end
|
||||
|
||||
def summary_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :user_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => 'conversations.team_id'
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_conversation_group_by_key
|
||||
{
|
||||
'account' => :account_id,
|
||||
'agent' => :assignee_id,
|
||||
'inbox' => :inbox_id,
|
||||
'team' => :team_id
|
||||
}[dimension_type]
|
||||
end
|
||||
|
||||
def summary_index_key
|
||||
summary_group_by_key.to_s.split('.').last
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
use_business_hours? ? :value_in_business_hours : :value
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,120 @@
|
||||
module Reports::ReportMetricRegistry
|
||||
# Describes one public report metric.
|
||||
# name: API-facing metric name requested by reports.
|
||||
# aggregate: whether the metric is a count or average.
|
||||
# raw_event_name: source reporting_events name for raw queries.
|
||||
# rollup_metric: source reporting_events_rollups metric for rollup queries.
|
||||
# summary_key: key used when this metric appears in grouped summary responses.
|
||||
# raw_count_strategy: optional raw-query counting rule, such as distinct conversations.
|
||||
Metric = Data.define(
|
||||
:name,
|
||||
:aggregate,
|
||||
:raw_event_name,
|
||||
:rollup_metric,
|
||||
:summary_key,
|
||||
:raw_count_strategy
|
||||
) do
|
||||
def initialize(name:, aggregate:, raw_event_name: nil, rollup_metric: nil, summary_key: nil, raw_count_strategy: nil) # rubocop:disable Metrics/ParameterLists
|
||||
super
|
||||
end
|
||||
|
||||
def average?
|
||||
aggregate == :average
|
||||
end
|
||||
|
||||
def count?
|
||||
aggregate == :count
|
||||
end
|
||||
|
||||
def rollup_supported?
|
||||
rollup_metric.present?
|
||||
end
|
||||
|
||||
def summary?
|
||||
summary_key.present?
|
||||
end
|
||||
end
|
||||
|
||||
METRICS = {
|
||||
conversations_count: Metric.new(
|
||||
name: :conversations_count,
|
||||
aggregate: :count
|
||||
),
|
||||
incoming_messages_count: Metric.new(
|
||||
name: :incoming_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
outgoing_messages_count: Metric.new(
|
||||
name: :outgoing_messages_count,
|
||||
aggregate: :count
|
||||
),
|
||||
avg_first_response_time: Metric.new(
|
||||
name: :avg_first_response_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :first_response,
|
||||
rollup_metric: :first_response,
|
||||
summary_key: :avg_first_response_time
|
||||
),
|
||||
avg_resolution_time: Metric.new(
|
||||
name: :avg_resolution_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolution_time,
|
||||
summary_key: :avg_resolution_time
|
||||
),
|
||||
reply_time: Metric.new(
|
||||
name: :reply_time,
|
||||
aggregate: :average,
|
||||
raw_event_name: :reply_time,
|
||||
rollup_metric: :reply_time,
|
||||
summary_key: :avg_reply_time
|
||||
),
|
||||
resolutions_count: Metric.new(
|
||||
name: :resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_resolved,
|
||||
rollup_metric: :resolutions_count,
|
||||
summary_key: :resolved_conversations_count
|
||||
),
|
||||
bot_resolutions_count: Metric.new(
|
||||
name: :bot_resolutions_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_resolved,
|
||||
rollup_metric: :bot_resolutions_count
|
||||
),
|
||||
bot_handoffs_count: Metric.new(
|
||||
name: :bot_handoffs_count,
|
||||
aggregate: :count,
|
||||
raw_event_name: :conversation_bot_handoff,
|
||||
rollup_metric: :bot_handoffs_count,
|
||||
raw_count_strategy: :distinct_conversation
|
||||
)
|
||||
}.freeze
|
||||
|
||||
SUMMARY_METRIC_NAMES = %i[
|
||||
resolutions_count
|
||||
avg_resolution_time
|
||||
avg_first_response_time
|
||||
reply_time
|
||||
].freeze
|
||||
|
||||
module_function
|
||||
|
||||
def fetch(name)
|
||||
return if name.blank?
|
||||
|
||||
METRICS[name.to_sym]
|
||||
end
|
||||
|
||||
def supported?(name)
|
||||
fetch(name).present?
|
||||
end
|
||||
|
||||
def rollup_supported?(name)
|
||||
fetch(name)&.rollup_supported? || false
|
||||
end
|
||||
|
||||
def summary_metrics
|
||||
SUMMARY_METRIC_NAMES.map { |metric_name| METRICS.fetch(metric_name) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
<tr>
|
||||
<td style="padding: 0 0 16px;">
|
||||
<span style="display: inline-block; padding: 6px 10px; border-radius: 999px; background-color: #EFF6FF; color: #1D4ED8; font-size: 12px; line-height: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;">
|
||||
<%= eyebrow %>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 0 12px;">
|
||||
<h1 style="margin: 0; font-size: 32px; line-height: 38px; font-weight: 700; color: #0F172A;"><%= heading %></h1>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 0 8px;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #334155;">Hi <%= recipient_name %>,</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 0 10px;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= intro_text %></p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 0 <%= detail_rows.any? || info_title.present? ? '16px' : '24px' %>;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= supporting_text %></p>
|
||||
</td>
|
||||
</tr>
|
||||
<% if detail_rows.any? %>
|
||||
<tr>
|
||||
<td style="padding: 0 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 16px; background-color: #F8FBFF;">
|
||||
<% detail_rows.each_with_index do |(label, value), index| %>
|
||||
<tr>
|
||||
<td style="padding: 16px 18px;<%= ' border-top: 1px solid #E2E8F0;' unless index.zero? %>">
|
||||
<p style="margin: 0 0 4px; font-size: 12px; line-height: 16px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748B;">
|
||||
<%= label %>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; font-weight: 600; color: #0F172A;"><%= value %></p>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% if action_url.present? %>
|
||||
<tr>
|
||||
<td style="padding: 0 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
|
||||
<tr>
|
||||
<td bgcolor="#2781F6" style="border-radius: 14px; background-color: #2781F6;">
|
||||
<%= link_to(
|
||||
action_text,
|
||||
action_url,
|
||||
style: 'display:block; width:100%; box-sizing:border-box; padding:12px 24px; font-size:14px; line-height:20px; font-weight:700; color:#FFFFFF; text-align:center; text-decoration:none;'
|
||||
) %>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 0 0 20px;">
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #64748B;">
|
||||
If the button does not work, open
|
||||
<%= link_to(
|
||||
'this secure link',
|
||||
action_url,
|
||||
style: 'color:#2781F6; text-decoration:none; font-weight:600;'
|
||||
) %>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<% elsif info_title.present? %>
|
||||
<tr>
|
||||
<td style="padding: 0 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; border: 1px solid #DBEAFE; border-radius: 16px; background-color: #EFF6FF;">
|
||||
<tr>
|
||||
<td style="padding: 18px;">
|
||||
<p style="margin: 0 0 6px; font-size: 14px; line-height: 21px; font-weight: 700; color: #0F172A;">
|
||||
<%= info_title %>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 14px; line-height: 21px; color: #475569;"><%= info_text %></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -1,29 +1,65 @@
|
||||
<p>Hi <%= @resource.name %>,</p>
|
||||
<%
|
||||
brand_name = global_config['BRAND_NAME'] || 'Chatwoot'
|
||||
recipient_name = @resource.name.presence || @resource.email
|
||||
account_user = @resource&.account_users&.first
|
||||
inviter = account_user&.inviter
|
||||
account_name = account_user&.account&.name
|
||||
invited_user = inviter.present? && @resource.unconfirmed_email.blank?
|
||||
|
||||
<% account_user = @resource&.account_users&.first %>
|
||||
eyebrow = 'Welcome'
|
||||
heading = 'Confirm your email to get started'
|
||||
intro_text =
|
||||
"Welcome to #{brand_name}. We just need to verify your email address before you can start using your account."
|
||||
supporting_text = 'This only takes a moment.'
|
||||
action_text = 'Confirm my account'
|
||||
action_url = frontend_url('auth/confirmation', confirmation_token: @token)
|
||||
info_title = nil
|
||||
info_text = nil
|
||||
detail_rows = []
|
||||
detail_rows << ['New email', @resource.unconfirmed_email] if @resource.unconfirmed_email.present?
|
||||
|
||||
<% if account_user&.inviter.present? && @resource.unconfirmed_email.blank? %>
|
||||
<p><%= account_user.inviter.name %>, with <%= account_user.account.name %>, has invited you to try out <%= global_config['BRAND_NAME'] || 'Chatwoot' %>.</p>
|
||||
<% end %>
|
||||
if @resource.unconfirmed_email.present?
|
||||
eyebrow = 'Email update'
|
||||
heading = 'Confirm your new email address'
|
||||
intro_text = "We received a request to update the email address on your #{brand_name} account."
|
||||
supporting_text = 'Confirm the new address below to finish the change.'
|
||||
action_text = 'Confirm email address'
|
||||
elsif @resource.confirmed?
|
||||
eyebrow = 'Account ready'
|
||||
heading = 'Your account is ready'
|
||||
intro_text = "Your #{brand_name} account is already active."
|
||||
supporting_text = 'Use the button below to sign in and continue where you left off.'
|
||||
action_text = 'Open my account'
|
||||
action_url = frontend_url('auth/sign_in')
|
||||
detail_rows = []
|
||||
elsif invited_user
|
||||
eyebrow = 'Workspace invitation'
|
||||
heading = account_name.present? ? "You're invited to join #{account_name}" : "You're invited to try #{brand_name}"
|
||||
intro_text = if account_name.present?
|
||||
"#{inviter.name} invited you to join the #{account_name} workspace on #{brand_name}."
|
||||
else
|
||||
"#{inviter.name} invited you to try #{brand_name}."
|
||||
end
|
||||
supporting_text = 'Create your account to start collaborating with your team.'
|
||||
action_text = 'Accept invitation'
|
||||
action_url = frontend_url(
|
||||
'auth/password/edit',
|
||||
reset_password_token: @resource.send(:set_reset_password_token)
|
||||
)
|
||||
detail_rows = [['Invited by', inviter.name]]
|
||||
detail_rows << ['Workspace', account_name] if account_name.present?
|
||||
end
|
||||
%>
|
||||
|
||||
<% if @resource.confirmed? %>
|
||||
<p>You can login to your <%= global_config['BRAND_NAME'] || 'Chatwoot' %> account through the link below:</p>
|
||||
<% else %>
|
||||
<% if account_user&.inviter.blank? %>
|
||||
<p>
|
||||
Welcome to <%= global_config['BRAND_NAME'] || 'Chatwoot' %>! We have a suite of powerful tools ready for you to explore. Before that we quickly need to verify your email address to know it's really you.
|
||||
</p>
|
||||
<% end %>
|
||||
<p>Please take a moment and click the link below and activate your account.</p>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @resource.unconfirmed_email.present? %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% elsif @resource.confirmed? %>
|
||||
<p><%= link_to 'Login to my account', frontend_url('auth/sign_in') %></p>
|
||||
<% elsif account_user&.inviter.present? %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/password/edit', reset_password_token: @resource.send(:set_reset_password_token)) %></p>
|
||||
<% else %>
|
||||
<p><%= link_to 'Confirm my account', frontend_url('auth/confirmation', confirmation_token: @token) %></p>
|
||||
<% end %>
|
||||
<%= render partial: 'devise/mailer/confirmation_body', locals: {
|
||||
action_text: action_text,
|
||||
action_url: action_url,
|
||||
detail_rows: detail_rows,
|
||||
eyebrow: eyebrow,
|
||||
heading: heading,
|
||||
info_text: info_text,
|
||||
info_title: info_title,
|
||||
intro_text: intro_text,
|
||||
recipient_name: recipient_name,
|
||||
supporting_text: supporting_text
|
||||
} %>
|
||||
|
||||
@@ -7,86 +7,129 @@
|
||||
<style type="text/css">
|
||||
img {
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-text-size-adjust: none;
|
||||
height: 100%;
|
||||
line-height: 1.6em;
|
||||
line-height: 21px;
|
||||
width: 100% !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #F4F7FB;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #F8FAFC;
|
||||
table {
|
||||
border-collapse: separate;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #2781F6;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
width: 100%;
|
||||
border: 1px solid #DCE7F5;
|
||||
border-radius: 24px;
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accent-bar {
|
||||
height: 6px;
|
||||
background-color: #2781F6;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 40px 36px 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 8px 0;
|
||||
text-align: center;
|
||||
color: #64748B;
|
||||
font-size: 13px;
|
||||
line-height: 21px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #2781F6;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 640px) {
|
||||
body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
h1 {
|
||||
font-size: 22px !important;
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
font-size: 28px !important;
|
||||
line-height: 34px !important;
|
||||
}
|
||||
h2 {
|
||||
font-size: 18px !important;
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
|
||||
.page-wrap {
|
||||
padding: 24px 12px 32px !important;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px !important;
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
h4 {
|
||||
font-weight: 800 !important;
|
||||
margin: 20px 0 5px !important;
|
||||
}
|
||||
.container {
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.content {
|
||||
padding: 0 !important;
|
||||
|
||||
.main-card {
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
|
||||
.content-wrap {
|
||||
padding: 10px !important;
|
||||
padding: 32px 24px 18px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; height: 100%; line-height: 1.6em; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
|
||||
<table class="body-wrap" style="width: 100%; background-color: #F8FAFC; margin: 0;" bgcolor="#F8FAFC">
|
||||
{% assign brand_name = global_config['BRAND_NAME'] %}
|
||||
{% if brand_name == nil %}
|
||||
{% assign brand_name = 'Chatwoot' %}
|
||||
{% endif %}
|
||||
{% assign brand_url = global_config['BRAND_URL'] %}
|
||||
|
||||
<body itemscope itemtype="http://schema.org/EmailMessage" style="font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,Helvetica,Arial,sans-serif; box-sizing: border-box; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none; width: 100% !important; line-height: 21px; background-color: #F4F7FB; margin: 0; padding: 0;" bgcolor="#F4F7FB">
|
||||
<table class="body-wrap" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; background-color: #F4F7FB; margin: 0;" bgcolor="#F4F7FB">
|
||||
<tr style="margin: 0;">
|
||||
<td class="container" width="600" style="display: block !important; max-width: 600px !important; clear: both !important; margin: 0 auto;" valign="top">
|
||||
<div class="content" style="display: block; margin: 0 auto; padding: 20px; text-align:center;">
|
||||
<table class="main" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="border-radius: 6px; background-color: #fff; text-align:left; margin: 0; border: 1px solid #e9e9e9; border-top:3px solid #0080f8;" bgcolor="#fff">
|
||||
<td align="center" class="page-wrap" style="padding: 32px 16px 40px;" valign="top">
|
||||
<table class="email-container" role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; max-width: 640px; margin: 0 auto;">
|
||||
<tr style="margin: 0;">
|
||||
<td style="margin: 0;">
|
||||
<table class="main-card" role="presentation" width="100%" cellpadding="0" cellspacing="0" itemprop="action" itemscope itemtype="http://schema.org/ConfirmAction" style="width: 100%; border: 1px solid #DCE7F5; border-radius: 24px; background-color: #FFFFFF; box-shadow: 0 12px 32px rgba(15, 23, 42, 0.06); overflow: hidden;" bgcolor="#FFFFFF">
|
||||
<tr style="margin: 0;">
|
||||
<td class="accent-bar" style="height: 6px; background-color: #2781F6; font-size: 0; line-height: 0;"> </td>
|
||||
</tr>
|
||||
<tr style="margin: 0;">
|
||||
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 40px 36px 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="width: 100%; margin: 0;">
|
||||
{{ content_for_layout }}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
{% if brand_name != '' %}
|
||||
<tr style="margin: 0;">
|
||||
<td class="content-wrap" style="vertical-align: top; margin: 0; padding: 20px; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;" valign="top">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 0;">
|
||||
{{ content_for_layout }}
|
||||
</table>
|
||||
<td class="footer" style="padding: 20px 8px 0; text-align: center; color: #64748B; font-size: 13px; line-height: 21px;">
|
||||
This email was sent by
|
||||
{% if brand_url != nil and brand_url != '' %}
|
||||
<a href="{{ brand_url }}" style="color: #2781F6; font-weight: 600; text-decoration: none;">{{ brand_name }}</a>.
|
||||
{% else %}
|
||||
<span style="color: #0F172A; font-weight: 600;">{{ brand_name }}</span>.
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" style="color: #93AFC8; margin: 0; padding: 0 20px 40px; text-align: center">
|
||||
<table width="100%" style="margin: 0;">
|
||||
<tr style="margin: 0;">
|
||||
{% if global_config['BRAND_NAME'] != '' %}
|
||||
<td class="content-block" style="font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; vertical-align: top; margin: 0; padding: 0 0 20px;" valign="top">
|
||||
Powered by
|
||||
<a href="{{ global_config['BRAND_URL'] }}" style="vertical-align: top; color: #93AFC8; text-align: center; margin: 0; padding: 0 0 20px;" align="center" valign="top">
|
||||
{{ global_config['BRAND_NAME'] }}
|
||||
</a>
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -32,7 +32,7 @@ as defined by the routes in the `admin/` namespace
|
||||
<ul class="my-4">
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-grid-line', url: super_admin_root_url, label: 'Dashboard' } %>
|
||||
<% Administrate::Namespace.new(namespace).resources.each do |resource| %>
|
||||
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings"].include? resource.resource %>
|
||||
<% next if ["account_users", "access_tokens", "installation_configs", "dashboard", "devise/sessions", "app_configs", "instance_statuses", "settings", "push_diagnostics"].include? resource.resource %>
|
||||
<%= render partial: "nav_item", locals: {
|
||||
icon: sidebar_icons[resource.resource.to_sym],
|
||||
url: resource_index_route(resource),
|
||||
@@ -48,6 +48,7 @@ as defined by the routes in the `admin/` namespace
|
||||
<ul class="my-4">
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-mail-send-fill', url: super_admin_push_diagnostics_url, label: 'Push Diagnostics' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-logout-circle-r-line', url: super_admin_logout_url, label: 'Logout' } %>
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<% content_for(:title) do %>Push Diagnostics<% end %>
|
||||
|
||||
<header class="main-content__header" role="banner">
|
||||
<h1 class="main-content__page-title" id="page-title">Push Diagnostics</h1>
|
||||
</header>
|
||||
|
||||
<section class="main-content__body">
|
||||
<p class="text-sm text-slate-600 mb-6">
|
||||
Send a test push notification to a specific user's registered devices to diagnose delivery issues.
|
||||
Results show the raw FCM / Web Push / relay response so you can see exactly what failed.
|
||||
</p>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-base font-semibold mb-2">1. Look up user</h2>
|
||||
<%= form_with url: super_admin_push_diagnostics_path, method: :get, local: true, class: 'flex gap-2 items-center' do |f| %>
|
||||
<%= f.text_field :user_query,
|
||||
value: @query,
|
||||
placeholder: 'user@example.com or numeric user ID',
|
||||
class: 'border border-slate-100 p-1.5 rounded-md w-80' %>
|
||||
<%= f.submit 'Look up', class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer' %>
|
||||
<% end %>
|
||||
<% if @query.present? && @user.nil? %>
|
||||
<p class="text-sm text-red-600 mt-2">No user found for "<%= @query %>".</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @user %>
|
||||
<div class="mb-6 border border-slate-100 rounded-md p-4 bg-slate-25">
|
||||
<p class="text-sm">
|
||||
<strong><%= @user.name %></strong>
|
||||
· <%= @user.email %>
|
||||
· ID <%= @user.id %>
|
||||
</p>
|
||||
<% if @user.accounts.any? %>
|
||||
<p class="text-xs text-slate-500 mt-1">
|
||||
Accounts:
|
||||
<% @user.accounts.each_with_index do |account, index| %>
|
||||
<%= ', ' if index.positive? %>
|
||||
<%= account.name %> (ID <%= account.id %>)
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h2 class="text-base font-semibold mb-2">2. Select subscriptions (<%= @subscriptions.count %>)</h2>
|
||||
<p class="text-xs text-amber-700 mb-3">
|
||||
⚠️ This sends a real push to every selected device. Use only when diagnosing a reported issue.
|
||||
</p>
|
||||
|
||||
<% if @subscriptions.empty? %>
|
||||
<p class="text-sm text-slate-600">This user has no push subscriptions registered.</p>
|
||||
<% else %>
|
||||
<%= form_with url: super_admin_push_diagnostics_path, method: :post, local: true do |f| %>
|
||||
<%= f.hidden_field :user_id, value: @user.id %>
|
||||
<div class="mb-4 max-w-2xl">
|
||||
<div class="mb-3">
|
||||
<%= label_tag :push_title, 'Title', class: 'block text-xs font-medium text-slate-600 mb-1' %>
|
||||
<%= text_field_tag :push_title,
|
||||
params[:push_title].presence || Notification::PushTestService.default_title,
|
||||
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag :push_body, 'Body', class: 'block text-xs font-medium text-slate-600 mb-1' %>
|
||||
<%= text_area_tag :push_body,
|
||||
params[:push_body].presence || Notification::PushTestService.default_body,
|
||||
rows: 2,
|
||||
class: 'border border-slate-100 p-1.5 rounded-md w-full text-sm' %>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1">Customers will see this text as a real push notification on their device.</p>
|
||||
</div>
|
||||
<table class="w-full text-sm border border-slate-100 rounded-md">
|
||||
<thead class="text-left text-xs text-slate-500 bg-slate-25">
|
||||
<tr>
|
||||
<th class="px-3 py-2 align-middle first:!pl-4 last:!pr-4"><input type="checkbox" id="toggle-all"></th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">ID</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device / endpoint</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device details</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Created</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Last updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @subscriptions.each do |sub| %>
|
||||
<% attrs = (sub.subscription_attributes || {}).stringify_keys %>
|
||||
<% if sub.browser_push? %>
|
||||
<% endpoint = attrs['endpoint'].to_s %>
|
||||
<% host = (begin; URI.parse(endpoint).host; rescue URI::InvalidURIError; nil; end) %>
|
||||
<% device_display = host.presence || endpoint.presence || '—' %>
|
||||
<% token_display = endpoint.present? ? "…#{endpoint.last(6)}" : '—' %>
|
||||
<% else %>
|
||||
<% device_display = attrs['device_id'].present? ? "…#{attrs['device_id'].to_s.last(6)}" : '—' %>
|
||||
<% token_display = attrs['push_token'].present? ? "…#{attrs['push_token'].to_s.last(6)}" : '—' %>
|
||||
<% end %>
|
||||
<% extra_attrs = attrs.except('endpoint', 'p256dh', 'auth', 'push_token', 'device_id') %>
|
||||
<tr class="border-t border-slate-100 align-middle">
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
|
||||
<%= check_box_tag 'subscription_ids[]', sub.id, false, class: 'subscription-checkbox' %>
|
||||
</td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.id %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= sub.subscription_type %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= device_display %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= token_display %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 text-xs align-top">
|
||||
<% if extra_attrs.present? %>
|
||||
<% extra_attrs.each do |k, v| %>
|
||||
<div><span class="text-slate-500"><%= k %>:</span> <span class="font-mono"><%= v.to_s.truncate(40) %></span></div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="text-slate-400">—</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap"><%= sub.created_at.strftime('%Y-%m-%d %H:%M') %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 whitespace-nowrap">
|
||||
<%= sub.updated_at.strftime('%Y-%m-%d %H:%M') %>
|
||||
<span class="text-xs text-slate-400">(<%= time_ago_in_words(sub.updated_at) %> ago)</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<%= f.submit 'Send Test Push to Selected',
|
||||
class: 'border border-slate-200 bg-slate-50 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
|
||||
<%= submit_tag 'Delete Selected Subscriptions',
|
||||
formaction: destroy_subscriptions_super_admin_push_diagnostics_path,
|
||||
formmethod: 'post',
|
||||
data: { confirm: "Delete the selected subscription(s)? The user won't receive pushes on those devices until their app re-registers." },
|
||||
class: 'border border-red-200 bg-red-50 text-red-700 px-3 py-1.5 rounded-md cursor-pointer font-medium' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @results.present? %>
|
||||
<div class="mb-8">
|
||||
<h2 class="text-base font-semibold mb-2">3. Results</h2>
|
||||
<table class="w-full text-sm border border-slate-100 rounded-md">
|
||||
<thead class="text-left text-xs text-slate-500 bg-slate-25">
|
||||
<tr>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Sub ID</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Type</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Device</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Push token</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Status</th>
|
||||
<th class="px-3 py-2 first:!pl-4 last:!pr-4">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @results.each do |r| %>
|
||||
<tr class="border-t border-slate-100 align-top">
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:id] %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4"><%= r[:type] %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:device] %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all"><%= r[:token_tail] %></td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4">
|
||||
<%
|
||||
color = {
|
||||
success: 'bg-green-100 text-green-800',
|
||||
failure: 'bg-red-100 text-red-800',
|
||||
skipped: 'bg-slate-100 text-slate-600'
|
||||
}[r[:status]]
|
||||
%>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs <%= color %>"><%= r[:status] %></span>
|
||||
</td>
|
||||
<td class="px-3 py-2 first:!pl-4 last:!pr-4 font-mono text-xs break-all whitespace-pre-wrap"><%= r[:message] %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<% content_for :javascript do %>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('toggle-all');
|
||||
if (!toggle) return;
|
||||
toggle.addEventListener('change', (event) => {
|
||||
document.querySelectorAll('.subscription-checkbox').forEach((cb) => {
|
||||
cb.checked = event.target.checked;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -488,6 +488,12 @@ en:
|
||||
agent_capacity_policy:
|
||||
inbox_already_assigned: 'Inbox has already been assigned to this policy'
|
||||
portals:
|
||||
articles:
|
||||
captain_not_available: 'Translation requires Captain to be enabled for this account'
|
||||
locale_not_available: 'Locale not available in this portal'
|
||||
category_not_found: 'Category not found in this portal'
|
||||
no_articles_found: 'No articles found to process'
|
||||
invalid_status: 'Invalid status value'
|
||||
send_instructions:
|
||||
email_required: 'Email is required'
|
||||
invalid_email_format: 'Invalid email format'
|
||||
@@ -496,3 +502,9 @@ en:
|
||||
subject: 'Finish setting up %{custom_domain}'
|
||||
ssl_status:
|
||||
custom_domain_not_configured: 'Custom domain is not configured'
|
||||
super_admin:
|
||||
push_diagnostics:
|
||||
user_not_found: 'User not found.'
|
||||
no_subscriptions_to_test: 'Select at least one subscription to test.'
|
||||
no_subscriptions_to_delete: 'Select at least one subscription to delete.'
|
||||
subscriptions_deleted: "Deleted %{count} subscription(s). The user's device(s) will re-register on next app launch."
|
||||
|
||||
@@ -358,6 +358,13 @@ Rails.application.routes.draw do
|
||||
resources :categories do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
namespace :articles do
|
||||
resource :bulk_actions, only: [] do
|
||||
post :translate
|
||||
patch :update_status
|
||||
delete :delete_articles
|
||||
end
|
||||
end
|
||||
resources :articles do
|
||||
post :reorder, on: :collection
|
||||
end
|
||||
@@ -625,6 +632,9 @@ Rails.application.routes.draw do
|
||||
root to: 'dashboard#index'
|
||||
|
||||
resource :app_config, only: [:show, :create]
|
||||
resource :push_diagnostics, only: [:show, :create] do
|
||||
post :destroy_subscriptions, on: :collection
|
||||
end
|
||||
|
||||
# order of resources affect the order of sidebar navigation in super admin
|
||||
resources :accounts, only: [:index, :new, :create, :show, :edit, :update, :destroy] do
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
module Enterprise::Api::V1::Accounts::Articles::BulkActionsController
|
||||
def translate
|
||||
return unless validate_translate_params?
|
||||
|
||||
duplicates = find_existing_translations
|
||||
if duplicates.any? && !ActiveModel::Type::Boolean.new.cast(permitted_params[:force])
|
||||
return render json: {
|
||||
duplicate_articles: duplicates.map { |a| { id: a.id, title: a.title } }
|
||||
}, status: :conflict
|
||||
end
|
||||
|
||||
@articles.find_each do |article|
|
||||
Captain::Articles::TranslateJob.perform_later(
|
||||
Current.account, article.id, @locale, @category&.id, Current.user
|
||||
)
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.permit(:locale, :category_id, :force, ids: [])
|
||||
end
|
||||
|
||||
def validate_translate_params?
|
||||
@locale = permitted_params[:locale]
|
||||
@category = @portal.categories.find_by(id: permitted_params[:category_id], locale: @locale)
|
||||
@articles = @portal.articles.where(id: permitted_params[:ids])
|
||||
|
||||
captain_available? && valid_locale? && valid_category? && valid_articles?
|
||||
end
|
||||
|
||||
def find_existing_translations
|
||||
root_ids = @articles.map { |a| Article.find_root_article_id(a) }
|
||||
@portal.articles.where(associated_article_id: root_ids, locale: @locale)
|
||||
end
|
||||
|
||||
def captain_available?
|
||||
return true if Current.account.feature_enabled?('captain_tasks')
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.captain_not_available'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_locale?
|
||||
return true if @portal.config['allowed_locales']&.include?(@locale)
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.locale_not_available'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_category?
|
||||
return true if permitted_params[:category_id].blank?
|
||||
return true if @category.present?
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.category_not_found'))
|
||||
false
|
||||
end
|
||||
|
||||
def valid_articles?
|
||||
return true if @articles.any?
|
||||
|
||||
render_could_not_create_error(I18n.t('portals.articles.no_articles_found'))
|
||||
false
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Captain::Articles::TranslateJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(account, article_id, target_locale, target_category_id, user)
|
||||
@account = account
|
||||
@source_article = account.articles.find(article_id)
|
||||
|
||||
target_language = language_name_for(target_locale)
|
||||
|
||||
translated_title = translate(@source_article.title, target_language: target_language, type: :title)
|
||||
translated_content = if @source_article.content.present?
|
||||
translate(@source_article.content, target_language: target_language, type: :content)
|
||||
else
|
||||
@source_article.content
|
||||
end
|
||||
|
||||
existing = find_existing_translation(target_locale)
|
||||
|
||||
if existing
|
||||
existing.update!(title: translated_title, content: translated_content, description: @source_article.description)
|
||||
else
|
||||
create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def translate(text, target_language:, type:)
|
||||
response = Captain::Llm::ArticleTranslationService.new(
|
||||
account: @account, text: text, target_language: target_language, type: type
|
||||
).perform
|
||||
raise "Translation failed: #{response[:error]}" if response[:error]
|
||||
|
||||
response[:message]
|
||||
end
|
||||
|
||||
def find_existing_translation(target_locale)
|
||||
root_id = Article.find_root_article_id(@source_article)
|
||||
@source_article.portal.articles.find_by(associated_article_id: root_id, locale: target_locale)
|
||||
end
|
||||
|
||||
def create_translated_article(translated_title, translated_content, target_locale, target_category_id, user)
|
||||
@source_article.portal.articles.create!(
|
||||
title: translated_title,
|
||||
content: translated_content,
|
||||
description: @source_article.description,
|
||||
category_id: target_category_id,
|
||||
locale: target_locale,
|
||||
author_id: user.id,
|
||||
status: :draft,
|
||||
associated_article_id: Article.find_root_article_id(@source_article)
|
||||
)
|
||||
end
|
||||
|
||||
def language_name_for(locale_code)
|
||||
language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
|
||||
language_map[locale_code] || locale_code
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
class Captain::Documents::PerformSyncJob < MutexApplicationJob
|
||||
queue_as :low
|
||||
|
||||
LOCK_TIMEOUT = 10.minutes
|
||||
|
||||
# Safety net for anything we didn't rescue by name — parser bugs, ActiveRecord blips,
|
||||
# random infra issues. Three attempts lets a real hiccup recover. The exhaustion block
|
||||
# absorbs the final exception so Sidekiq doesn't layer its own retry policy on top, and
|
||||
# is the single place we report to Sentry — handle_unexpected_failure logs but does not
|
||||
# capture, so a deterministic bug emits one Sentry event instead of one per attempt.
|
||||
# Goes first because retry_on handlers dispatch bottom-to-top.
|
||||
retry_on StandardError, wait: 5.seconds, attempts: 3 do |job, error|
|
||||
document = job.arguments.first
|
||||
ChatwootExceptionTracker.new(error, account: document.account).capture_exception
|
||||
job.send(:log_sync_outcome, document, result: :unexpected_retry_exhausted,
|
||||
error_code: 'sync_error',
|
||||
exception_class: error.class.name)
|
||||
end
|
||||
|
||||
# Permanent errors (404, 403, empty content) — no point retrying, discard immediately.
|
||||
# Document is already marked failed by SyncService before the exception reaches here.
|
||||
discard_on(Captain::Documents::SyncService::PermanentSyncError)
|
||||
|
||||
# TransientSyncError is raised by SyncService when the customer's site is unreachable —
|
||||
# timeouts, TLS errors, 5xx, connection drops. Four attempts with backoff gives the site
|
||||
# a chance to recover before we give up.
|
||||
#
|
||||
# The exhaustion block absorbs the exception so it doesn't propagate to Sentry —
|
||||
# site flakiness isn't an application bug.
|
||||
retry_on(
|
||||
Captain::Documents::SyncService::TransientSyncError,
|
||||
wait: ->(executions) { [30.seconds, 2.minutes, 5.minutes][executions - 1] || 5.minutes },
|
||||
attempts: 4
|
||||
) do |job, error|
|
||||
document = job.arguments.first
|
||||
job.send(:log_sync_outcome, document, result: :transient_retry_exhausted, error_code: error.message)
|
||||
end
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
discard_on ActiveRecord::RecordNotFound
|
||||
|
||||
def perform(document)
|
||||
start_time = Time.current
|
||||
return if document.pdf_document?
|
||||
|
||||
with_lock(lock_key(document), LOCK_TIMEOUT) do
|
||||
document.update!(sync_status: :syncing, last_sync_attempted_at: Time.current)
|
||||
result = Captain::Documents::SyncService.new(document.reload).perform
|
||||
log_sync_outcome(document, result: result, duration_ms: duration_ms_since(start_time))
|
||||
end
|
||||
rescue LockAcquisitionError
|
||||
log_sync_outcome(document, result: :already_syncing)
|
||||
rescue Captain::Documents::SyncService::PermanentSyncError => e
|
||||
log_failure_and_raise(document, :permanent_failure, e, start_time)
|
||||
rescue Captain::Documents::SyncService::TransientSyncError => e
|
||||
log_failure_and_raise(document, :transient_failure, e, start_time)
|
||||
rescue StandardError => e
|
||||
handle_unexpected_failure(document, e, start_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def log_sync_outcome(document, **fields)
|
||||
payload = {
|
||||
document_id: document.id,
|
||||
account_id: document.account_id,
|
||||
assistant_id: document.assistant_id
|
||||
}.merge(fields)
|
||||
Rails.logger.info("[Captain::Documents::PerformSyncJob] #{payload.to_json}")
|
||||
end
|
||||
|
||||
def log_failure_and_raise(document, result, error, start_time)
|
||||
log_sync_outcome(document, result: result, error_code: error.message,
|
||||
duration_ms: duration_ms_since(start_time))
|
||||
raise error
|
||||
end
|
||||
|
||||
def handle_unexpected_failure(document, error, start_time)
|
||||
document.update!(
|
||||
sync_status: :failed,
|
||||
last_sync_error_code: 'sync_error',
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
log_sync_outcome(document, result: :unexpected_failure, error_code: 'sync_error',
|
||||
exception_class: error.class.name,
|
||||
duration_ms: duration_ms_since(start_time))
|
||||
raise error
|
||||
end
|
||||
|
||||
def lock_key(document)
|
||||
format(::Redis::Alfred::CAPTAIN_DOCUMENT_SYNC_MUTEX, document_id: document.id)
|
||||
end
|
||||
|
||||
def duration_ms_since(start_time)
|
||||
((Time.current - start_time) * 1000).round
|
||||
end
|
||||
end
|
||||
@@ -26,7 +26,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_standard_faqs(document)
|
||||
Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name, account_id: document.account_id).generate
|
||||
Captain::Llm::FaqGeneratorService.new(document: document).generate
|
||||
end
|
||||
|
||||
def build_paginated_service(document, options)
|
||||
@@ -62,7 +62,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def reset_previous_responses(response_document)
|
||||
response_document.responses.destroy_all
|
||||
response_document.responses.where(edited: false).destroy_all
|
||||
end
|
||||
|
||||
def create_response(faq, document)
|
||||
|
||||
@@ -62,6 +62,7 @@ class Captain::Document < ApplicationRecord
|
||||
|
||||
def pdf_document?
|
||||
return true if pdf_file.attached? && pdf_file.blob.content_type == 'application/pdf'
|
||||
return true if external_link&.start_with?('PDF:')
|
||||
|
||||
external_link&.ends_with?('.pdf')
|
||||
end
|
||||
@@ -90,6 +91,14 @@ class Captain::Document < ApplicationRecord
|
||||
self.metadata = (metadata || {}).merge('last_sync_error_code' => value)
|
||||
end
|
||||
|
||||
def sync_step
|
||||
metadata&.dig('sync_step')
|
||||
end
|
||||
|
||||
def store_sync_step(step)
|
||||
update!(metadata: (metadata || {}).merge('sync_step' => step))
|
||||
end
|
||||
|
||||
def openai_file_id
|
||||
metadata&.dig('openai_file_id')
|
||||
end
|
||||
@@ -108,6 +117,10 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def to_llm_metadata
|
||||
{ document_id: id, assistant_id: assistant_id, external_link: external_link }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enqueue_crawl_job
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
class Captain::Documents::SinglePageFetcher
|
||||
Result = Struct.new(:success, :title, :content, :error_code, keyword_init: true)
|
||||
|
||||
CONTENT_MAX_LENGTH = 200_000
|
||||
TITLE_MAX_LENGTH = 255 # captain_documents.name is a varchar(255)
|
||||
|
||||
def initialize(url)
|
||||
@url = url
|
||||
end
|
||||
|
||||
def fetch
|
||||
result = firecrawl_configured? ? fetch_with_firecrawl : fetch_with_fallback
|
||||
validate_content(result)
|
||||
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ETIMEDOUT
|
||||
Result.new(success: false, error_code: 'timeout')
|
||||
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, OpenSSL::SSL::SSLError
|
||||
Result.new(success: false, error_code: 'fetch_failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def firecrawl_configured?
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY')&.value.present?
|
||||
end
|
||||
|
||||
def fetch_with_firecrawl
|
||||
response = Captain::Tools::FirecrawlService.new.scrape(@url)
|
||||
handle_firecrawl_response(response)
|
||||
end
|
||||
|
||||
def handle_firecrawl_response(response)
|
||||
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
|
||||
|
||||
data = response.parsed_response&.dig('data')
|
||||
target_error = firecrawl_target_error_code(data)
|
||||
return Result.new(success: false, error_code: target_error) if target_error
|
||||
|
||||
Result.new(
|
||||
success: true,
|
||||
title: data&.dig('metadata', 'title')&.truncate(TITLE_MAX_LENGTH, omission: ''),
|
||||
content: data&.dig('markdown')&.truncate(CONTENT_MAX_LENGTH, omission: '')
|
||||
)
|
||||
end
|
||||
|
||||
# Firecrawl returns API 200 even when the scraped page itself failed —
|
||||
# the target page's real status lives in data.metadata.statusCode.
|
||||
def firecrawl_target_error_code(data)
|
||||
status = data&.dig('metadata', 'statusCode')
|
||||
return nil if status.blank? || (200..299).cover?(status)
|
||||
|
||||
http_error_code(status)
|
||||
end
|
||||
|
||||
def fetch_with_fallback
|
||||
response = HTTParty.get(@url)
|
||||
return Result.new(success: false, error_code: http_error_code(response.code)) unless response.success?
|
||||
|
||||
parser = Captain::Tools::HtmlPageParser.new(response.body)
|
||||
Result.new(
|
||||
success: true,
|
||||
title: parser.title&.truncate(TITLE_MAX_LENGTH, omission: ''),
|
||||
content: parser.body_markdown&.truncate(CONTENT_MAX_LENGTH, omission: '')
|
||||
)
|
||||
end
|
||||
|
||||
def validate_content(result)
|
||||
return result unless result.success && result.content.blank?
|
||||
|
||||
Result.new(success: false, error_code: 'content_empty')
|
||||
end
|
||||
|
||||
def http_error_code(status_code)
|
||||
case status_code
|
||||
when 404 then 'not_found'
|
||||
when 401, 403 then 'access_denied'
|
||||
when 408, 504 then 'timeout'
|
||||
else 'fetch_failed'
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
class Captain::Documents::SyncService
|
||||
class PermanentSyncError < StandardError
|
||||
end
|
||||
|
||||
class TransientSyncError < StandardError
|
||||
end
|
||||
|
||||
PERMANENT_ERROR_CODES = %w[not_found access_denied content_empty].freeze
|
||||
|
||||
def initialize(document)
|
||||
@document = document
|
||||
end
|
||||
|
||||
def perform
|
||||
@document.store_sync_step('fetching')
|
||||
result = Captain::Documents::SinglePageFetcher.new(@document.external_link).fetch
|
||||
|
||||
unless result.success
|
||||
mark_failed(result.error_code)
|
||||
raise_for_error_code(result.error_code)
|
||||
end
|
||||
|
||||
@document.store_sync_step('comparing')
|
||||
fingerprint = compute_fingerprint(result.content)
|
||||
|
||||
if fingerprint == @document.content_fingerprint
|
||||
mark_synced
|
||||
return :unchanged
|
||||
end
|
||||
|
||||
@document.store_sync_step('updating')
|
||||
update_content(result, fingerprint)
|
||||
:updated
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def compute_fingerprint(content)
|
||||
Digest::SHA256.hexdigest(content.gsub(/\s+/, ' ').strip)
|
||||
end
|
||||
|
||||
def mark_failed(error_code)
|
||||
@document.update!(
|
||||
sync_status: :failed,
|
||||
last_sync_error_code: error_code,
|
||||
last_sync_attempted_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def mark_synced
|
||||
@document.update!(
|
||||
sync_status: :synced,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
)
|
||||
end
|
||||
|
||||
def update_content(result, fingerprint)
|
||||
@document.update!(
|
||||
content: result.content,
|
||||
name: result.title.presence || @document.name,
|
||||
content_fingerprint: fingerprint,
|
||||
sync_status: :synced,
|
||||
last_synced_at: Time.current,
|
||||
last_sync_attempted_at: Time.current,
|
||||
last_sync_error_code: nil
|
||||
)
|
||||
end
|
||||
|
||||
def raise_for_error_code(error_code)
|
||||
raise PermanentSyncError, error_code if PERMANENT_ERROR_CODES.include?(error_code)
|
||||
|
||||
raise TransientSyncError, error_code
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class Captain::Llm::ArticleTranslationService < Captain::BaseTaskService
|
||||
TYPES = %i[title content].freeze
|
||||
|
||||
pattr_initialize [:account!, :text!, :target_language!, :type!]
|
||||
|
||||
def perform
|
||||
raise ArgumentError, "Invalid type: #{type}" unless TYPES.include?(type)
|
||||
|
||||
response = make_api_call(model: translation_model, messages: messages)
|
||||
return response if response[:error]
|
||||
|
||||
response.merge(message: response[:message].strip)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def messages
|
||||
[
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: text }
|
||||
]
|
||||
end
|
||||
|
||||
def system_prompt
|
||||
type == :title ? title_system_prompt : content_system_prompt
|
||||
end
|
||||
|
||||
def event_name
|
||||
'article_translation'
|
||||
end
|
||||
|
||||
def llm_credential
|
||||
@llm_credential ||= system_llm_credential
|
||||
end
|
||||
|
||||
def translation_model
|
||||
@translation_model ||= InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL
|
||||
end
|
||||
|
||||
def title_system_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a professional translator.
|
||||
Translate the following text to #{target_language}.
|
||||
Return only the translated text, no explanations or extra formatting.
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
def content_system_prompt
|
||||
<<~SYSTEM_PROMPT_MESSAGE
|
||||
You are a professional translator. Translate the following content to #{target_language}.
|
||||
The content is markdown that may contain embedded HTML blocks.
|
||||
Rules:
|
||||
- Translate ONLY the visible text content (headings, paragraphs, list items, table cells, etc.).
|
||||
- Preserve ALL markdown formatting exactly: headings (#), bold (**), italic (*), links, lists, code blocks, blockquotes, tables, horizontal rules.
|
||||
- Preserve ALL HTML tags, attributes, and structure exactly as they are.
|
||||
- Do NOT translate or modify: URLs, image src/alt attributes, link href values, class names, IDs, data attributes, code blocks, or any HTML attribute values.
|
||||
- Keep all image tags (both markdown  and HTML <img>), iframes, and embedded media completely unchanged.
|
||||
- Preserve all line breaks, blank lines, and whitespace patterns.
|
||||
- Return ONLY the translated content, no wrapping or explanations.
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
end
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
def initialize(content, language = 'english', account_id: nil)
|
||||
def initialize(document:)
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
@account_id = account_id
|
||||
@document = document
|
||||
@content = document.content
|
||||
@language = document.account.locale_english_name
|
||||
@account_id = document.account_id
|
||||
end
|
||||
|
||||
def generate
|
||||
@@ -40,10 +41,15 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseAiService
|
||||
messages: [
|
||||
{ role: 'system', content: system_prompt },
|
||||
{ role: 'user', content: @content }
|
||||
]
|
||||
],
|
||||
metadata: document_metadata
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
|
||||
def parse_response(content)
|
||||
return [] if content.nil?
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
account_id: @document&.account_id,
|
||||
feature_name: 'faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages]
|
||||
messages: params[:messages],
|
||||
metadata: document_metadata
|
||||
}
|
||||
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@@ -214,12 +215,11 @@ class Captain::Llm::PaginatedFaqGeneratorService < Llm::LegacyBaseOpenAiService
|
||||
feature_name: 'paginated_faq_generation',
|
||||
model: @model,
|
||||
messages: params[:messages],
|
||||
metadata: {
|
||||
document_id: @document&.id,
|
||||
start_page: start_page,
|
||||
end_page: end_page,
|
||||
iteration: @iterations_completed + 1
|
||||
}
|
||||
metadata: document_metadata.merge(start_page: start_page, end_page: end_page, iteration: @iterations_completed + 1)
|
||||
}
|
||||
end
|
||||
|
||||
def document_metadata
|
||||
@document&.to_llm_metadata || {}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,11 +3,15 @@ class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any substantive information.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
|
||||
**Completeness**: Extract ALL substantive information from the source content. Every detail, example, procedure, warning, code block, identifier, limit, definition, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the substantive source content entirely.
|
||||
|
||||
**Self-contained answers**: Every answer must contain the information that answers its question. The answer must be the substance, not directions to where the substance lives. If a source section provides only a reference, link, or pointer to where the information can be found — without containing that information itself — omit the FAQ for that section. An FAQ whose answer redirects the reader is worse than no FAQ at all.
|
||||
|
||||
**Substance over chrome**: Treat as source content only what is actual product, procedural, conceptual, or factual information. Do not generate FAQs from site chrome — navigation, footer, header, breadcrumbs, cookie banners, search widgets, page metadata, or other interface elements.
|
||||
|
||||
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
|
||||
|
||||
@@ -29,18 +33,21 @@ class Captain::Llm::SystemPromptsService
|
||||
## Guidelines
|
||||
|
||||
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
|
||||
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, code, identifiers, limits, and definitions present in the source.
|
||||
- **Information Preservation**: Never omit examples, procedures, warnings, code, IDs, limits, or definitions in the name of brevity.
|
||||
- **No Deflecting FAQs**: Do not create FAQs whose answer would only tell the reader to open another link, guide, or document. If the source contains useful factual content in link text, labels, lists, or summaries (e.g., a curated list of supported integrations, plan features, resources, or article indexes), preserve that content as the answer. If it only points elsewhere without providing the answer itself, skip it.
|
||||
- **JSON Validity**: Always return properly formatted, valid JSON
|
||||
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
|
||||
|
||||
## Process
|
||||
1. Read the entire provided content carefully
|
||||
2. Identify all key information points, procedures, and examples
|
||||
3. Create questions that cover each information point
|
||||
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
|
||||
5. Verify that combined FAQs represent the complete original content.
|
||||
6. Format as valid JSON
|
||||
2. Identify all key information points: procedures, examples, code, identifiers, limits, definitions, warnings, and explanations
|
||||
3. For each candidate section, verify the source contains the substance that would answer the question. If the source only points to where the substance lives, skip the section.
|
||||
4. Disregard interface chrome (navigation, footer, header, cookie banners, breadcrumbs, page metadata).
|
||||
5. Create questions that cover each remaining substantive information point
|
||||
6. Write self-contained answers that preserve all relevant details from the source. Be concise where possible, but never trade away steps, examples, warnings, code, IDs, limits, or definitions for brevity.
|
||||
7. Verify the combined FAQs represent the complete substantive source content (excluding redirect-only sections and chrome).
|
||||
8. Format as valid JSON
|
||||
PROMPT
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Captain::Tools::FirecrawlService
|
||||
BASE_URL = 'https://api.firecrawl.dev/v1'.freeze
|
||||
FIRECRAWL_EXCLUDE_TAGS = %w[iframe .sidebar .cookie-banner [role=navigation] [role=banner] [role=contentinfo]].freeze
|
||||
|
||||
def initialize
|
||||
@api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value
|
||||
raise 'Missing API key' if @api_key.empty?
|
||||
@@ -6,7 +9,7 @@ class Captain::Tools::FirecrawlService
|
||||
|
||||
def perform(url, webhook_url, crawl_limit = 10)
|
||||
HTTParty.post(
|
||||
'https://api.firecrawl.dev/v1/crawl',
|
||||
"#{BASE_URL}/crawl",
|
||||
body: crawl_payload(url, webhook_url, crawl_limit),
|
||||
headers: headers
|
||||
)
|
||||
@@ -14,6 +17,14 @@ class Captain::Tools::FirecrawlService
|
||||
raise "Failed to crawl URL: #{e.message}"
|
||||
end
|
||||
|
||||
def scrape(url)
|
||||
HTTParty.post(
|
||||
"#{BASE_URL}/scrape",
|
||||
body: scrape_payload(url),
|
||||
headers: headers
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def crawl_payload(url, webhook_url, crawl_limit)
|
||||
@@ -23,14 +34,22 @@ class Captain::Tools::FirecrawlService
|
||||
ignoreSitemap: false,
|
||||
limit: crawl_limit,
|
||||
webhook: webhook_url,
|
||||
scrapeOptions: {
|
||||
onlyMainContent: false,
|
||||
formats: ['markdown'],
|
||||
excludeTags: ['iframe']
|
||||
}
|
||||
scrapeOptions: scrape_options
|
||||
}.to_json
|
||||
end
|
||||
|
||||
def scrape_payload(url)
|
||||
{ url: url }.merge(scrape_options).to_json
|
||||
end
|
||||
|
||||
def scrape_options
|
||||
{
|
||||
onlyMainContent: true,
|
||||
formats: ['markdown'],
|
||||
excludeTags: FIRECRAWL_EXCLUDE_TAGS
|
||||
}
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@api_key}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class Captain::Tools::HtmlPageParser
|
||||
attr_reader :doc
|
||||
|
||||
def initialize(html)
|
||||
@doc = Nokogiri::HTML(html)
|
||||
end
|
||||
|
||||
def title
|
||||
@doc.at_xpath('//title')&.text&.strip
|
||||
end
|
||||
|
||||
def body_markdown
|
||||
ReverseMarkdown.convert(@doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,8 @@ class Captain::Tools::SimplePageCrawlService
|
||||
|
||||
def initialize(external_link)
|
||||
@external_link = external_link
|
||||
@doc = Nokogiri::HTML(HTTParty.get(external_link).body)
|
||||
@parser = Captain::Tools::HtmlPageParser.new(HTTParty.get(external_link).body)
|
||||
@doc = @parser.doc
|
||||
end
|
||||
|
||||
def page_links
|
||||
@@ -11,12 +12,11 @@ class Captain::Tools::SimplePageCrawlService
|
||||
end
|
||||
|
||||
def page_title
|
||||
title_element = @doc.at_xpath('//title')
|
||||
title_element&.text&.strip
|
||||
@parser.title
|
||||
end
|
||||
|
||||
def body_text_content
|
||||
ReverseMarkdown.convert @doc.at_xpath('//body'), unknown_tags: :bypass, github_flavored: true
|
||||
@parser.body_markdown
|
||||
end
|
||||
|
||||
def meta_description
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user