Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7edbe3a2b | ||
|
|
bac921133a | ||
|
|
8818d276b9 | ||
|
|
90b9dba9f9 | ||
|
|
2a21d075f6 | ||
|
|
b8545019e1 | ||
|
|
6a7ca9dd3b | ||
|
|
6c9efc4e92 | ||
|
|
34d8741b9b | ||
|
|
7bf76057c2 | ||
|
|
a3c7f3b204 | ||
|
|
49b0ab0e1f | ||
|
|
926a9d8a69 | ||
|
|
9caceea858 | ||
|
|
8670f66155 | ||
|
|
2767bd434b | ||
|
|
ce2e10e89e | ||
|
|
56275b750e | ||
|
|
299bc6c0a4 | ||
|
|
7522457740 | ||
|
|
e4ef2de8c8 | ||
|
|
d0b1c055e8 | ||
|
|
cf134deb37 | ||
|
|
b8b62ad0f1 | ||
|
|
4e26c5b4bb | ||
|
|
43e0f8a141 | ||
|
|
91d8a4e2a3 | ||
|
|
8b977b35a8 | ||
|
|
62cbeae95f | ||
|
|
7c7459b734 | ||
|
|
0849d2e070 | ||
|
|
cd1a214d98 | ||
|
|
f9cc702030 | ||
|
|
74db16158d | ||
|
|
e8edc9ebf5 | ||
|
|
e785620921 | ||
|
|
0af9900be2 | ||
|
|
84fda8a323 | ||
|
|
1c7e60880d | ||
|
|
8e27ca4282 | ||
|
|
ee5e20551d | ||
|
|
df79c0bbde | ||
|
|
647cfc2d83 | ||
|
|
961ec86ba9 |
@@ -144,7 +144,7 @@ jobs:
|
|||||||
# Backend tests with parallelization
|
# Backend tests with parallelization
|
||||||
backend-tests:
|
backend-tests:
|
||||||
<<: *defaults
|
<<: *defaults
|
||||||
parallelism: 20
|
parallelism: 18
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- checkout
|
||||||
- node/install:
|
- node/install:
|
||||||
|
|||||||
+3
-3
@@ -272,9 +272,9 @@ AZURE_APP_SECRET=
|
|||||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||||
|
|
||||||
|
|
||||||
# AI powered features
|
# AI powered features (Captain)
|
||||||
## OpenAI key
|
# The OpenAI API key and endpoint for Captain are not configured via .env.
|
||||||
# OPENAI_API_KEY=
|
# Set them at Super Admin > App Configs > Captain (CAPTAIN_OPEN_AI_API_KEY, CAPTAIN_OPEN_AI_ENDPOINT).
|
||||||
|
|
||||||
# Housekeeping/Performance related configurations
|
# Housekeeping/Performance related configurations
|
||||||
# Set to true if you want to remove stale contact inboxes
|
# Set to true if you want to remove stale contact inboxes
|
||||||
|
|||||||
@@ -43,13 +43,18 @@
|
|||||||
|
|
||||||
## General Guidelines
|
## General Guidelines
|
||||||
|
|
||||||
- MVP focus: Least code change, happy-path only
|
- Prefer the smallest production-ready change that solves the current problem.
|
||||||
- No unnecessary defensive programming
|
- Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary.
|
||||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
- When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior.
|
||||||
|
- For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks.
|
||||||
|
- Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully.
|
||||||
|
- Prefer existing repo dependencies/client libraries over hand-rolled protocol code for auth, signing, parsing, or API plumbing.
|
||||||
|
- Avoid one-use private helpers unless they hide real complexity or make the main flow meaningfully easier to read.
|
||||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||||
- Break down complex tasks into small, testable units
|
- Break down complex tasks into small, testable units
|
||||||
- Iterate after confirmation
|
- Iterate after confirmation
|
||||||
- Avoid writing specs unless explicitly asked
|
- Avoid writing specs unless explicitly asked
|
||||||
|
- In specs, avoid custom helper methods for setup/data. Prefer `let` values and direct per-example setup; only add a helper when it removes meaningful repeated complexity.
|
||||||
- Remove dead/unreachable/unused code
|
- Remove dead/unreachable/unused code
|
||||||
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
- Don’t write multiple versions or backups for the same logic — pick the best approach and implement it
|
||||||
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
- Prefer `with_modified_env` (from spec helpers) over stubbing `ENV` directly in specs
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ gem 'reverse_markdown'
|
|||||||
|
|
||||||
gem 'iso-639'
|
gem 'iso-639'
|
||||||
gem 'ruby-openai'
|
gem 'ruby-openai'
|
||||||
gem 'ai-agents', '>= 0.10.0'
|
gem 'ai-agents', '>= 0.12.0'
|
||||||
|
|
||||||
# TODO: Move this gem as a dependency of ai-agents
|
# TODO: Move this gem as a dependency of ai-agents
|
||||||
gem 'ruby_llm', '>= 1.14.1'
|
gem 'ruby_llm', '>= 1.14.1'
|
||||||
|
|||||||
+4
-4
@@ -126,7 +126,7 @@ GEM
|
|||||||
jbuilder (~> 2)
|
jbuilder (~> 2)
|
||||||
rails (>= 4.2, < 7.2)
|
rails (>= 4.2, < 7.2)
|
||||||
selectize-rails (~> 0.6)
|
selectize-rails (~> 0.6)
|
||||||
ai-agents (0.10.0)
|
ai-agents (0.12.0)
|
||||||
ruby_llm (~> 1.14)
|
ruby_llm (~> 1.14)
|
||||||
annotaterb (4.20.0)
|
annotaterb (4.20.0)
|
||||||
activerecord (>= 6.0.0)
|
activerecord (>= 6.0.0)
|
||||||
@@ -198,7 +198,7 @@ GEM
|
|||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rexml
|
rexml
|
||||||
crass (1.0.6)
|
crass (1.0.7)
|
||||||
cronex (0.15.0)
|
cronex (0.15.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
unicode (>= 0.4.4.5)
|
unicode (>= 0.4.4.5)
|
||||||
@@ -570,7 +570,7 @@ GEM
|
|||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
mock_redis (0.36.0)
|
mock_redis (0.36.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.8.0)
|
msgpack (1.8.3)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
multi_xml (0.9.1)
|
multi_xml (0.9.1)
|
||||||
bigdecimal (>= 3.1, < 5)
|
bigdecimal (>= 3.1, < 5)
|
||||||
@@ -1058,7 +1058,7 @@ DEPENDENCIES
|
|||||||
administrate (>= 0.20.1)
|
administrate (>= 0.20.1)
|
||||||
administrate-field-active_storage (>= 1.0.3)
|
administrate-field-active_storage (>= 1.0.3)
|
||||||
administrate-field-belongs_to_search (>= 0.9.0)
|
administrate-field-belongs_to_search (>= 0.9.0)
|
||||||
ai-agents (>= 0.10.0)
|
ai-agents (>= 0.12.0)
|
||||||
annotaterb
|
annotaterb
|
||||||
attr_extras
|
attr_extras
|
||||||
audited (~> 5.4, >= 5.4.1)
|
audited (~> 5.4, >= 5.4.1)
|
||||||
|
|||||||
@@ -91,15 +91,17 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
|||||||
|
|
||||||
def fallback_params(attachment)
|
def fallback_params(attachment)
|
||||||
{
|
{
|
||||||
fallback_title: attachment['title'],
|
fallback_title: attachment['title'] || attachment.dig('payload', 'title'),
|
||||||
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
external_url: attachment['url'] || attachment.dig('payload', 'url')
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
# Facebook shared posts point to page URLs, not downloadable media URLs.
|
||||||
|
# Both `share` and `post` attachment types carry a page URL rather than a media file,
|
||||||
|
# so map them to `fallback` (which keeps the title/link without attempting a download).
|
||||||
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
# Keep this Facebook-only so Messenger/Instagram share attachments still use the parent media handling.
|
||||||
def normalize_file_type(type)
|
def normalize_file_type(type)
|
||||||
return :fallback if type.to_sym == :share
|
return :fallback if [:share, :post].include?(type.to_sym)
|
||||||
|
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
class V2::Reports::DrilldownBuilder
|
||||||
|
include DateRangeHelper
|
||||||
|
include TimezoneHelper
|
||||||
|
|
||||||
|
DEFAULT_GROUP_BY = 'day'.freeze
|
||||||
|
DEFAULT_PAGE = 1
|
||||||
|
DEFAULT_PER_PAGE = 25
|
||||||
|
MAX_PER_PAGE = 100
|
||||||
|
SUPPORTED_GROUP_BY = %w[hour day week month year].freeze
|
||||||
|
SUPPORTED_DIMENSION_TYPES = %w[account inbox agent label team].freeze
|
||||||
|
MESSAGE_METRICS = {
|
||||||
|
'incoming_messages_count' => :incoming,
|
||||||
|
'outgoing_messages_count' => :outgoing
|
||||||
|
}.freeze
|
||||||
|
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||||
|
|
||||||
|
pattr_initialize :account, :params
|
||||||
|
|
||||||
|
def self.supported_dimension_type?(type) = SUPPORTED_DIMENSION_TYPES.include?((type.presence || 'account').to_s)
|
||||||
|
|
||||||
|
def build
|
||||||
|
records = paginated_records.to_a
|
||||||
|
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def meta
|
||||||
|
{
|
||||||
|
metric: metric,
|
||||||
|
record_type: record_type,
|
||||||
|
bucket: {
|
||||||
|
since: bucket_range.begin.to_i,
|
||||||
|
until: bucket_range.end.to_i
|
||||||
|
},
|
||||||
|
current_page: current_page,
|
||||||
|
per_page: per_page,
|
||||||
|
total_count: paginated_records.total_count,
|
||||||
|
conversation_count: conversation_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_count
|
||||||
|
return paginated_records.total_count if conversation_metric?
|
||||||
|
|
||||||
|
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginated_records
|
||||||
|
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
|
||||||
|
end
|
||||||
|
|
||||||
|
def drilldown_scope
|
||||||
|
if message_metric?
|
||||||
|
message_scope
|
||||||
|
elsif conversation_metric?
|
||||||
|
conversation_scope
|
||||||
|
else
|
||||||
|
reporting_event_scope
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_scope
|
||||||
|
scope.messages
|
||||||
|
.where(account_id: account.id, created_at: bucket_range)
|
||||||
|
.public_send(MESSAGE_METRICS.fetch(metric))
|
||||||
|
.includes(:sender, conversation: [:assignee, :contact, :inbox])
|
||||||
|
.reorder(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_scope
|
||||||
|
scope.conversations
|
||||||
|
.where(account_id: account.id, created_at: bucket_range)
|
||||||
|
.includes(:assignee, :contact, :inbox)
|
||||||
|
.order(created_at: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reporting_event_scope
|
||||||
|
events = scope.reporting_events
|
||||||
|
.where(account_id: account.id, name: raw_event_name, created_at: bucket_range)
|
||||||
|
.includes(:user, :inbox, conversation: [:assignee, :contact, :inbox])
|
||||||
|
.order(created_at: :desc)
|
||||||
|
|
||||||
|
if raw_count_strategy == :exclude_bot_handoffs
|
||||||
|
events = events.where.not(conversation_id: bot_handoff_conversation_ids_subquery)
|
||||||
|
elsif raw_count_strategy == :distinct_conversation
|
||||||
|
events = events.where(id: distinct_conversation_event_ids(events))
|
||||||
|
end
|
||||||
|
|
||||||
|
events
|
||||||
|
end
|
||||||
|
|
||||||
|
def bot_handoff_conversation_ids_subquery
|
||||||
|
scope.reporting_events
|
||||||
|
.where(account_id: account.id, name: :conversation_bot_handoff, created_at: range)
|
||||||
|
.where.not(conversation_id: nil)
|
||||||
|
.select(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def distinct_conversation_event_ids(events)
|
||||||
|
events.reorder(nil)
|
||||||
|
.where.not(conversation_id: nil)
|
||||||
|
.select('MAX(reporting_events.id)')
|
||||||
|
.group(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_serializer(records)
|
||||||
|
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(
|
||||||
|
account,
|
||||||
|
metric,
|
||||||
|
use_business_hours?,
|
||||||
|
records
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bucket_range
|
||||||
|
@bucket_range ||= begin
|
||||||
|
bucket_start = Time.zone.at(params[:bucket_timestamp].to_i).in_time_zone(timezone)
|
||||||
|
bucket_end = bucket_end_for(bucket_start)
|
||||||
|
requested_start = Time.zone.at(params[:since].to_i)
|
||||||
|
requested_end = Time.zone.at(params[:until].to_i)
|
||||||
|
|
||||||
|
[bucket_start, requested_start].max...[bucket_end, requested_end].min
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def bucket_end_for(bucket_start)
|
||||||
|
{
|
||||||
|
'hour' => bucket_start + 1.hour,
|
||||||
|
'day' => bucket_start + 1.day,
|
||||||
|
'week' => bucket_start + 1.week,
|
||||||
|
'month' => bucket_start + 1.month,
|
||||||
|
'year' => bucket_start + 1.year
|
||||||
|
}.fetch(group_by)
|
||||||
|
end
|
||||||
|
|
||||||
|
def scope
|
||||||
|
case dimension_type
|
||||||
|
when 'account' then account
|
||||||
|
when 'inbox' then inbox
|
||||||
|
when 'agent' then user
|
||||||
|
when 'label' then label
|
||||||
|
when 'team' then team
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unsupported drilldown dimension type: #{dimension_type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inbox = @inbox ||= account.inboxes.find(params[:id])
|
||||||
|
|
||||||
|
def user = @user ||= account.users.find(params[:id])
|
||||||
|
|
||||||
|
def label = @label ||= account.labels.find(params[:id])
|
||||||
|
|
||||||
|
def team = @team ||= account.teams.find(params[:id])
|
||||||
|
|
||||||
|
def metric
|
||||||
|
params[:metric].to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def report_metric
|
||||||
|
@report_metric ||= Reports::ReportMetricRegistry.fetch(metric)
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_event_name
|
||||||
|
report_metric&.raw_event_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def raw_count_strategy
|
||||||
|
report_metric&.raw_count_strategy
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_type
|
||||||
|
return 'message' if message_metric? || MESSAGE_EVENT_METRICS.include?(metric)
|
||||||
|
|
||||||
|
'conversation'
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_metric?
|
||||||
|
MESSAGE_METRICS.key?(metric)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_metric?
|
||||||
|
metric == 'conversations_count'
|
||||||
|
end
|
||||||
|
|
||||||
|
def dimension_type
|
||||||
|
(params[:type].presence || 'account').to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def group_by
|
||||||
|
@group_by ||= SUPPORTED_GROUP_BY.include?(params[:group_by].to_s) ? params[:group_by].to_s : DEFAULT_GROUP_BY
|
||||||
|
end
|
||||||
|
|
||||||
|
def timezone
|
||||||
|
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_page
|
||||||
|
[params[:page].to_i, DEFAULT_PAGE].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def per_page
|
||||||
|
requested_per_page = params[:per_page].to_i
|
||||||
|
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
|
||||||
|
|
||||||
|
[requested_per_page, MAX_PER_PAGE].min
|
||||||
|
end
|
||||||
|
|
||||||
|
def use_business_hours?
|
||||||
|
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
class V2::Reports::DrilldownRecordSerializer
|
||||||
|
MESSAGE_EVENT_METRICS = %w[avg_first_response_time reply_time].freeze
|
||||||
|
|
||||||
|
attr_reader :account, :metric, :use_business_hours, :records
|
||||||
|
|
||||||
|
def initialize(account, metric, use_business_hours, records = [])
|
||||||
|
@account = account
|
||||||
|
@metric = metric
|
||||||
|
@use_business_hours = use_business_hours
|
||||||
|
@records = records
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize(record)
|
||||||
|
return serialize_message(record) if record.is_a?(Message)
|
||||||
|
return serialize_conversation_event(record) if record.is_a?(ReportingEvent)
|
||||||
|
|
||||||
|
serialize_conversation(record)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serialize_message(message, metric_value: nil, occurred_at: nil)
|
||||||
|
{
|
||||||
|
record_type: 'message',
|
||||||
|
conversation: conversation_attributes(message.conversation),
|
||||||
|
message: message_attributes(message),
|
||||||
|
metric_value: metric_value,
|
||||||
|
occurred_at: (occurred_at || message.created_at).to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_conversation_event(event)
|
||||||
|
inferred_message = inferred_message_for(event)
|
||||||
|
if inferred_message.present?
|
||||||
|
return serialize_message(
|
||||||
|
inferred_message,
|
||||||
|
metric_value: event_metric_value(event),
|
||||||
|
occurred_at: event_timestamp(event)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
serialize_conversation(
|
||||||
|
event.conversation,
|
||||||
|
metric_value: event_metric_value(event),
|
||||||
|
occurred_at: event_timestamp(event),
|
||||||
|
event_name: event.name
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialize_conversation(conversation, metric_value: nil, occurred_at: nil, event_name: nil)
|
||||||
|
serialized_record = {
|
||||||
|
record_type: 'conversation',
|
||||||
|
conversation: conversation_attributes(conversation),
|
||||||
|
message: nil,
|
||||||
|
metric_value: metric_value,
|
||||||
|
occurred_at: (occurred_at || conversation&.created_at)&.to_i
|
||||||
|
}
|
||||||
|
serialized_record[:event_name] = event_name if event_name.present?
|
||||||
|
serialized_record
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_attributes(conversation)
|
||||||
|
return {} if conversation.blank?
|
||||||
|
|
||||||
|
{
|
||||||
|
id: conversation.id,
|
||||||
|
display_id: conversation.display_id,
|
||||||
|
contact_id: conversation.contact_id,
|
||||||
|
contact_name: conversation.contact&.name,
|
||||||
|
inbox_id: conversation.inbox_id,
|
||||||
|
inbox_name: conversation.inbox&.name,
|
||||||
|
assignee_id: conversation.assignee_id,
|
||||||
|
assignee_name: conversation.assignee&.name,
|
||||||
|
status: conversation.status,
|
||||||
|
created_at: conversation.created_at.to_i,
|
||||||
|
last_activity_at: conversation.last_activity_at.to_i,
|
||||||
|
last_message: last_message_attributes(conversation)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_attributes(message)
|
||||||
|
{
|
||||||
|
id: message.id,
|
||||||
|
content: message.content,
|
||||||
|
message_type: message.message_type,
|
||||||
|
sender_name: message.sender&.try(:name),
|
||||||
|
created_at: message.created_at.to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def last_message_attributes(conversation)
|
||||||
|
message = latest_messages_by_conversation_id[conversation.id]
|
||||||
|
return if message.blank?
|
||||||
|
|
||||||
|
message_attributes(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
def inferred_message_for(event)
|
||||||
|
return unless MESSAGE_EVENT_METRICS.include?(metric)
|
||||||
|
return if event.conversation.blank? || event.event_end_time.blank?
|
||||||
|
|
||||||
|
inferred_messages_by_event_id[event.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
def first_response_event_with_user?(event)
|
||||||
|
metric == 'avg_first_response_time' && event.user_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_inference_range(event)
|
||||||
|
(event.event_end_time - 1.second)..(event.event_end_time + 1.second)
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_metric_value(event)
|
||||||
|
use_business_hours ? event.value_in_business_hours : event.value
|
||||||
|
end
|
||||||
|
|
||||||
|
def event_timestamp(event)
|
||||||
|
event.event_end_time || event.created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_messages_by_conversation_id
|
||||||
|
@latest_messages_by_conversation_id ||= if conversation_ids.blank?
|
||||||
|
{}
|
||||||
|
else
|
||||||
|
latest_messages.index_by(&:conversation_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_messages
|
||||||
|
Message
|
||||||
|
.where(account_id: account.id, conversation_id: conversation_ids)
|
||||||
|
.where.not(message_type: :activity)
|
||||||
|
.select('DISTINCT ON (messages.conversation_id) messages.*')
|
||||||
|
.reorder(Arel.sql('messages.conversation_id, messages.created_at DESC, messages.id DESC'))
|
||||||
|
.includes(:sender)
|
||||||
|
end
|
||||||
|
|
||||||
|
def inferred_messages_by_event_id
|
||||||
|
@inferred_messages_by_event_id ||= inference_events.each_with_object({}) do |event, messages_by_event_id|
|
||||||
|
messages_by_event_id[event.id] = inferred_message_candidates.find do |message|
|
||||||
|
message_matches_event?(message, event)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inferred_message_candidates
|
||||||
|
@inferred_message_candidates ||= if inference_events.blank?
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
inferred_messages.to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def inferred_messages
|
||||||
|
Message
|
||||||
|
.where(account_id: account.id, conversation_id: inference_events.map(&:conversation_id).uniq)
|
||||||
|
.where(created_at: inference_time_range)
|
||||||
|
.where(message_type: %i[outgoing template])
|
||||||
|
.includes(:sender)
|
||||||
|
.reorder(created_at: :desc, id: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_matches_event?(message, event)
|
||||||
|
message.conversation_id == event.conversation_id &&
|
||||||
|
message.created_at.between?(
|
||||||
|
message_inference_range(event).begin,
|
||||||
|
message_inference_range(event).end
|
||||||
|
) &&
|
||||||
|
message_sender_matches_event?(message, event)
|
||||||
|
end
|
||||||
|
|
||||||
|
def message_sender_matches_event?(message, event)
|
||||||
|
return true unless first_response_event_with_user?(event)
|
||||||
|
|
||||||
|
message.sender_id == event.user_id && message.sender_type == 'User'
|
||||||
|
end
|
||||||
|
|
||||||
|
def inference_time_range
|
||||||
|
event_end_times = inference_events.map(&:event_end_time)
|
||||||
|
|
||||||
|
(event_end_times.min - 1.second)..(event_end_times.max + 1.second)
|
||||||
|
end
|
||||||
|
|
||||||
|
def inference_events
|
||||||
|
@inference_events ||= records.select do |record|
|
||||||
|
record.is_a?(ReportingEvent) && record.conversation_id.present? && record.event_end_time.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_ids
|
||||||
|
@conversation_ids ||= records.filter_map { |record| conversation_id_for(record) }.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_id_for(record)
|
||||||
|
return record.conversation_id if record.is_a?(Message) || record.is_a?(ReportingEvent)
|
||||||
|
|
||||||
|
record.id
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -40,8 +40,8 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def reorder
|
def reorder
|
||||||
Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
positions = Article.update_positions(portal: @portal, positions_hash: params[:positions_hash])
|
||||||
head :ok
|
render json: { positions: positions }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC
|
|||||||
def assignment_policy_params
|
def assignment_policy_params
|
||||||
params.require(:assignment_policy).permit(
|
params.require(:assignment_policy).permit(
|
||||||
:name, :description, :assignment_order, :conversation_priority,
|
:name, :description, :assignment_order, :conversation_priority,
|
||||||
:fair_distribution_limit, :fair_distribution_window, :enabled
|
:fair_distribution_limit, :fair_distribution_window, :enabled,
|
||||||
|
:exclude_older_than_hours
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
params_to_update = captain_params
|
params_to_update = captain_params
|
||||||
@current_account.captain_models = params_to_update[:captain_models] if params_to_update[:captain_models]
|
@current_account.captain_models = params_to_update[:captain_models] if params_to_update.key?(:captain_models)
|
||||||
@current_account.captain_features = params_to_update[:captain_features] if params_to_update[:captain_features]
|
@current_account.captain_features = params_to_update[:captain_features] if params_to_update.key?(:captain_features)
|
||||||
@current_account.save!
|
@current_account.save!
|
||||||
|
|
||||||
render json: preferences_payload
|
render json: preferences_payload
|
||||||
@@ -38,7 +38,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
|||||||
|
|
||||||
def merged_captain_models
|
def merged_captain_models
|
||||||
existing_models = @current_account.captain_models || {}
|
existing_models = @current_account.captain_models || {}
|
||||||
existing_models.merge(permitted_captain_models)
|
existing_models.merge(permitted_captain_models).compact_blank.presence
|
||||||
end
|
end
|
||||||
|
|
||||||
def merged_captain_features
|
def merged_captain_features
|
||||||
@@ -47,29 +47,30 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_captain_models
|
def permitted_captain_models
|
||||||
params.require(:captain_models).permit(
|
params.require(:captain_models).permit(*captain_feature_keys).to_h.stringify_keys
|
||||||
:editor, :assistant, :copilot, :label_suggestion,
|
|
||||||
:audio_transcription, :help_center_search
|
|
||||||
).to_h.stringify_keys
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def permitted_captain_features
|
def permitted_captain_features
|
||||||
params.require(:captain_features).permit(
|
params.require(:captain_features).permit(*captain_feature_keys).to_h.stringify_keys
|
||||||
:editor, :assistant, :copilot, :label_suggestion,
|
end
|
||||||
:audio_transcription, :help_center_search
|
|
||||||
).to_h.stringify_keys
|
def captain_feature_keys
|
||||||
|
Llm::Models.feature_keys.map(&:to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
def features_with_account_preferences
|
def features_with_account_preferences
|
||||||
preferences = Current.account.captain_preferences
|
preferences = Current.account.captain_preferences
|
||||||
account_features = preferences[:features] || {}
|
account_features = preferences[:features] || {}
|
||||||
account_models = preferences[:models] || {}
|
|
||||||
|
|
||||||
Llm::Models.feature_keys.index_with do |feature_key|
|
Llm::Models.feature_keys.index_with do |feature_key|
|
||||||
config = Llm::Models.feature_config(feature_key)
|
config = Llm::Models.feature_config(feature_key)
|
||||||
|
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
|
||||||
config.merge(
|
config.merge(
|
||||||
enabled: account_features[feature_key] == true,
|
enabled: account_features[feature_key] == true,
|
||||||
selected: account_models[feature_key] || config[:default]
|
model: route[:model],
|
||||||
|
selected: route[:model],
|
||||||
|
provider: route[:provider],
|
||||||
|
source: route[:source]
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
|||||||
end
|
end
|
||||||
|
|
||||||
render json: { content: translated_content }
|
render json: { content: translated_content }
|
||||||
|
rescue Google::Cloud::Error => e
|
||||||
|
# `details` carries the clean human message; `message` includes gRPC debug noise
|
||||||
|
render_could_not_create_error(e.details.presence || e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
|||||||
end
|
end
|
||||||
|
|
||||||
render_response(
|
render_response(
|
||||||
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user)
|
dyte_processor_service.add_participant_to_meeting(@message.content_attributes['data']['meeting_id'], Current.user, @message)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseController
|
||||||
before_action :check_admin_authorization?
|
before_action :check_admin_authorization?
|
||||||
|
|
||||||
|
ONBOARDING_STEP_KEY = 'onboarding_step'.freeze
|
||||||
|
STEP_ACCOUNT_DETAILS = 'account_details'.freeze
|
||||||
|
STEP_INBOX_SETUP = 'inbox_setup'.freeze
|
||||||
|
ONBOARDING_STEPS = [STEP_ACCOUNT_DETAILS, STEP_INBOX_SETUP].freeze
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
return render json: { error: 'Invalid onboarding step' }, status: :unprocessable_entity unless ONBOARDING_STEPS.include?(params[:onboarding_step])
|
||||||
|
|
||||||
@account = Current.account
|
@account = Current.account
|
||||||
finalize = finalizing_account_details?
|
# The client declares the step it is completing; `account_details` runs
|
||||||
|
# `complete_account_details`, and so on. The known-step guard above keeps the
|
||||||
@account.assign_attributes(account_params)
|
# client value from `send`-ing an arbitrary method.
|
||||||
@account.custom_attributes.merge!(custom_attributes_params)
|
send("complete_#{params[:onboarding_step]}")
|
||||||
@account.custom_attributes.delete('onboarding_step') if finalize
|
|
||||||
@account.save!
|
|
||||||
|
|
||||||
# TODO: re-enable when the help center generation UI is ready to surface progress
|
|
||||||
# Onboarding::HelpCenterCreationService.new(@account, Current.user).perform if finalize && website.present?
|
|
||||||
|
|
||||||
render 'api/v1/accounts/update', format: :json
|
render 'api/v1/accounts/update', format: :json
|
||||||
end
|
end
|
||||||
@@ -22,12 +24,48 @@ class Api::V1::Accounts::OnboardingsController < Api::V1::Accounts::BaseControll
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def finalizing_account_details?
|
def complete_account_details
|
||||||
@account.custom_attributes['onboarding_step'] == 'account_details'
|
# Only act while the cursor still points here, so a stale replay after
|
||||||
|
# onboarding finished can't re-enter it.
|
||||||
|
return unless current_step == STEP_ACCOUNT_DETAILS
|
||||||
|
|
||||||
|
@account.assign_attributes(account_params)
|
||||||
|
@account.custom_attributes.merge!(custom_attributes_params)
|
||||||
|
|
||||||
|
# inbox_setup is a cloud-only step (DEPLOYMENT_ENV config, not a hardcoded
|
||||||
|
# environment check); self-hosted finishes onboarding here.
|
||||||
|
if ChatwootApp.chatwoot_cloud?
|
||||||
|
move_to_step(STEP_INBOX_SETUP)
|
||||||
|
create_onboarding_inboxes
|
||||||
|
else
|
||||||
|
finish_onboarding
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def website
|
def complete_inbox_setup
|
||||||
custom_attributes_params[:website]
|
# Only finalize while the cursor still points here, so a stale or out-of-order
|
||||||
|
# request can't end onboarding early. Replays are no-ops.
|
||||||
|
return unless current_step == STEP_INBOX_SETUP
|
||||||
|
|
||||||
|
finish_onboarding
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_step
|
||||||
|
@account.custom_attributes[ONBOARDING_STEP_KEY]
|
||||||
|
end
|
||||||
|
|
||||||
|
def move_to_step(step)
|
||||||
|
@account.custom_attributes[ONBOARDING_STEP_KEY] = step
|
||||||
|
@account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def finish_onboarding
|
||||||
|
@account.custom_attributes.delete(ONBOARDING_STEP_KEY)
|
||||||
|
@account.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_onboarding_inboxes
|
||||||
|
Onboarding::WebWidgetCreationService.new(@account, Current.user).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def team_params
|
def team_params
|
||||||
params.require(:team).permit(:name, :description, :allow_auto_assign)
|
params.require(:team).permit(:name, :description, :allow_auto_assign, :icon, :icon_color)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ class Api::V1::Widget::Integrations::DyteController < Api::V1::Widget::BaseContr
|
|||||||
|
|
||||||
response = dyte_processor_service.add_participant_to_meeting(
|
response = dyte_processor_service.add_participant_to_meeting(
|
||||||
@message.content_attributes['data']['meeting_id'],
|
@message.content_attributes['data']['meeting_id'],
|
||||||
@conversation.contact
|
@conversation.contact,
|
||||||
|
@message
|
||||||
)
|
)
|
||||||
render_response(response)
|
render_response(response)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
|
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def drilldown
|
||||||
|
return head :unauthorized unless Current.account_user.administrator?
|
||||||
|
return head :unprocessable_entity unless valid_drilldown_params?
|
||||||
|
|
||||||
|
render json: V2::Reports::DrilldownBuilder.new(Current.account, drilldown_params).build
|
||||||
|
end
|
||||||
|
|
||||||
def conversations
|
def conversations
|
||||||
return head :unprocessable_entity if params[:type].blank?
|
return head :unprocessable_entity if params[:type].blank?
|
||||||
|
|
||||||
@@ -133,6 +140,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def drilldown_params
|
||||||
|
permitted_params = params.permit(
|
||||||
|
:metric, :id, :since, :until, :group_by, :timezone_offset, :bucket_timestamp, :page, :per_page
|
||||||
|
).to_h.symbolize_keys
|
||||||
|
permitted_params.merge(
|
||||||
|
type: (params[:type].presence || 'account').to_sym,
|
||||||
|
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_drilldown_params?
|
||||||
|
%i[metric bucket_timestamp since until].all? { |param| params[param].present? } &&
|
||||||
|
Reports::ReportMetricRegistry.supported?(params[:metric]) &&
|
||||||
|
V2::Reports::DrilldownBuilder.supported_dimension_type?(params[:type]) && Reports::DrilldownTimestampValidator.valid?(params)
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_params
|
def conversation_params
|
||||||
{
|
{
|
||||||
type: params[:type].to_sym,
|
type: params[:type].to_sym,
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
module PortalHomeData
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_home_data
|
||||||
|
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
||||||
|
@visible_categories = @portal.categories
|
||||||
|
.where(locale: @locale)
|
||||||
|
.joins(:articles).where(articles: { status: :published })
|
||||||
|
.order(position: :asc)
|
||||||
|
.group('categories.id')
|
||||||
|
@popular_topics = @visible_categories.first(3)
|
||||||
|
@featured = base_articles.order_by_views.limit(6)
|
||||||
|
@category_contributors = build_category_contributors(@visible_categories)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_category_contributors(categories)
|
||||||
|
category_ids = categories.map(&:id)
|
||||||
|
return {} if category_ids.empty?
|
||||||
|
|
||||||
|
@portal.articles
|
||||||
|
.published
|
||||||
|
.where(locale: @locale, category_id: category_ids)
|
||||||
|
.includes(:author)
|
||||||
|
.group_by(&:category_id)
|
||||||
|
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
class DashboardController < ActionController::Base
|
class DashboardController < ActionController::Base
|
||||||
include SwitchLocale
|
include SwitchLocale
|
||||||
|
include PortalHomeData
|
||||||
|
|
||||||
GLOBAL_CONFIG_KEYS = %w[
|
GLOBAL_CONFIG_KEYS = %w[
|
||||||
LOGO
|
LOGO
|
||||||
@@ -63,6 +64,10 @@ class DashboardController < ActionController::Base
|
|||||||
return unless @portal
|
return unless @portal
|
||||||
|
|
||||||
@locale = @portal.default_locale
|
@locale = @portal.default_locale
|
||||||
|
if @portal.layout == 'documentation'
|
||||||
|
request.variant = :documentation
|
||||||
|
load_home_data
|
||||||
|
end
|
||||||
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def switch_locale_with_portal(&)
|
def switch_locale_with_portal(&)
|
||||||
@locale = validate_and_get_locale(params[:locale])
|
# Keep @locale as the portal's own locale code (e.g. th_TH) for content queries,
|
||||||
|
# while UI translations fall back to an available I18n locale (e.g. th).
|
||||||
|
@locale = params[:locale]
|
||||||
|
|
||||||
I18n.with_locale(@locale, &)
|
I18n.with_locale(validate_and_get_locale(@locale), &)
|
||||||
end
|
end
|
||||||
|
|
||||||
def switch_locale_with_article(&)
|
def switch_locale_with_article(&)
|
||||||
@@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
|||||||
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
Rails.logger.info "Article: not found for slug: #{params[:article_slug]}"
|
||||||
render_404 && return if article.blank?
|
render_404 && return if article.blank?
|
||||||
|
|
||||||
article_locale = if article.category.present?
|
@locale = if article.category.present?
|
||||||
article.category.locale
|
article.category.locale
|
||||||
else
|
else
|
||||||
article.locale
|
article.locale
|
||||||
end
|
end
|
||||||
@locale = validate_and_get_locale(article_locale)
|
I18n.with_locale(validate_and_get_locale(@locale), &)
|
||||||
I18n.with_locale(@locale, &)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def allow_iframe_requests
|
def allow_iframe_requests
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController
|
||||||
|
include PortalHomeData
|
||||||
|
|
||||||
before_action :ensure_custom_domain_request, only: [:show]
|
before_action :ensure_custom_domain_request, only: [:show]
|
||||||
before_action :redirect_to_portal_with_locale, only: [:show]
|
before_action :redirect_to_portal_with_locale, only: [:show]
|
||||||
before_action :portal
|
before_action :portal
|
||||||
@@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
|
|||||||
portal
|
portal
|
||||||
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_home_data
|
|
||||||
base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category)
|
|
||||||
@visible_categories = @portal.categories
|
|
||||||
.where(locale: @locale)
|
|
||||||
.joins(:articles).where(articles: { status: :published })
|
|
||||||
.order(position: :asc)
|
|
||||||
.group('categories.id')
|
|
||||||
@popular_topics = @visible_categories.first(3)
|
|
||||||
@featured = base_articles.order_by_views.limit(6)
|
|
||||||
@category_contributors = build_category_contributors(@visible_categories)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_category_contributors(categories)
|
|
||||||
category_ids = categories.map(&:id)
|
|
||||||
return {} if category_ids.empty?
|
|
||||||
|
|
||||||
@portal.articles
|
|
||||||
.published
|
|
||||||
.where(locale: @locale, category_id: category_ids)
|
|
||||||
.includes(:author)
|
|
||||||
.group_by(&:category_id)
|
|
||||||
.transform_values { |articles| articles.filter_map(&:author).uniq.first(3) }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
|
|||||||
#
|
#
|
||||||
def resource_params
|
def resource_params
|
||||||
permitted_params = super
|
permitted_params = super
|
||||||
permitted_params[:limits] = permitted_params[:limits].to_h.compact
|
permitted_params[:limits] = permitted_params[:limits].to_h.compact if permitted_params.key?(:limits)
|
||||||
|
permitted_params[:captain_models] = permitted_params[:captain_models].to_h.compact_blank.presence if permitted_params.key?(:captain_models)
|
||||||
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
|
permitted_params[:selected_feature_flags] = params[:enabled_features].keys.map(&:to_sym) if params[:enabled_features].present?
|
||||||
permitted_params
|
permitted_params
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
|
|
||||||
# Add all_features last so it appears after manually_managed_features
|
# Add all_features last so it appears after manually_managed_features
|
||||||
attributes[:all_features] = AccountFeaturesField
|
attributes[:all_features] = AccountFeaturesField
|
||||||
|
attributes[:captain_models] = CaptainModelOverridesField
|
||||||
|
|
||||||
attributes
|
attributes
|
||||||
else
|
else
|
||||||
@@ -57,6 +58,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
attrs = %i[custom_attributes limits]
|
attrs = %i[custom_attributes limits]
|
||||||
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||||
attrs << :all_features
|
attrs << :all_features
|
||||||
|
attrs << :captain_models
|
||||||
attrs
|
attrs
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
@@ -79,6 +81,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
attrs = %i[limits]
|
attrs = %i[limits]
|
||||||
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
attrs << :manually_managed_features if ChatwootApp.chatwoot_cloud?
|
||||||
attrs << :all_features
|
attrs << :all_features
|
||||||
|
attrs << :captain_models
|
||||||
attrs
|
attrs
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
@@ -117,7 +120,7 @@ class AccountDashboard < Administrate::BaseDashboard
|
|||||||
# to prevent an error from being raised (wrong number of arguments)
|
# to prevent an error from being raised (wrong number of arguments)
|
||||||
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
|
# Reference: https://github.com/thoughtbot/administrate/pull/2356/files#diff-4e220b661b88f9a19ac527c50d6f1577ef6ab7b0bed2bfdf048e22e6bfa74a05R204
|
||||||
def permitted_attributes(action)
|
def permitted_attributes(action)
|
||||||
attrs = super + [limits: {}]
|
attrs = super + [limits: {}, captain_models: {}]
|
||||||
|
|
||||||
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
|
# Add manually_managed_features to permitted attributes only for Chatwoot Cloud
|
||||||
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
|
attrs << { manually_managed_features: [] } if ChatwootApp.chatwoot_cloud?
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ saml:
|
|||||||
enterprise: true
|
enterprise: true
|
||||||
custom_branding:
|
custom_branding:
|
||||||
name: 'Custom Branding'
|
name: 'Custom Branding'
|
||||||
description: 'Apply your own branding to this installation.'
|
description: 'Apply your own branding to this installation, including email template layout customization.'
|
||||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||||
icon: 'icon-paint-brush-line'
|
icon: 'icon-paint-brush-line'
|
||||||
config_key: 'custom_branding'
|
config_key: 'custom_branding'
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
module SuperAdmin::NavigationHelper
|
module SuperAdmin::NavigationHelper
|
||||||
def settings_open?
|
def settings_open?
|
||||||
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
|
return false if params[:controller] == 'super_admin/email_layouts' && params[:account_id].present?
|
||||||
|
|
||||||
|
params[:controller].in? %w[super_admin/settings super_admin/app_configs super_admin/email_layouts]
|
||||||
end
|
end
|
||||||
|
|
||||||
def settings_pages
|
def settings_pages
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDrilldown({
|
||||||
|
metric,
|
||||||
|
bucketTimestamp,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
type = 'account',
|
||||||
|
id,
|
||||||
|
groupBy,
|
||||||
|
businessHours,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
signal,
|
||||||
|
}) {
|
||||||
|
const requestConfig = {
|
||||||
|
params: {
|
||||||
|
metric,
|
||||||
|
bucket_timestamp: bucketTimestamp,
|
||||||
|
since: from,
|
||||||
|
until: to,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
group_by: groupBy,
|
||||||
|
business_hours: businessHours,
|
||||||
|
timezone_offset: getTimeOffset(),
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
requestConfig.signal = signal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return axios.get(`${this.url}/drilldown`, requestConfig);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line default-param-last
|
// eslint-disable-next-line default-param-last
|
||||||
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
|
||||||
return axios.get(`${this.url}/summary`, {
|
return axios.get(`${this.url}/summary`, {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import reportsAPI from '../reports';
|
import reportsAPI from '../reports';
|
||||||
import ApiClient from '../ApiClient';
|
import ApiClient from '../ApiClient';
|
||||||
|
|
||||||
|
const timezoneOffset = () => -new Date().getTimezoneOffset() / 60;
|
||||||
|
|
||||||
describe('#Reports API', () => {
|
describe('#Reports API', () => {
|
||||||
it('creates correct instance', () => {
|
it('creates correct instance', () => {
|
||||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||||
@@ -11,6 +13,7 @@ describe('#Reports API', () => {
|
|||||||
expect(reportsAPI).toHaveProperty('update');
|
expect(reportsAPI).toHaveProperty('update');
|
||||||
expect(reportsAPI).toHaveProperty('delete');
|
expect(reportsAPI).toHaveProperty('delete');
|
||||||
expect(reportsAPI).toHaveProperty('getReports');
|
expect(reportsAPI).toHaveProperty('getReports');
|
||||||
|
expect(reportsAPI).toHaveProperty('getDrilldown');
|
||||||
expect(reportsAPI).toHaveProperty('getSummary');
|
expect(reportsAPI).toHaveProperty('getSummary');
|
||||||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||||
expect(reportsAPI).toHaveProperty('getLabelReports');
|
expect(reportsAPI).toHaveProperty('getLabelReports');
|
||||||
@@ -42,11 +45,14 @@ describe('#Reports API', () => {
|
|||||||
});
|
});
|
||||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
|
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', {
|
||||||
params: {
|
params: {
|
||||||
|
business_hours: undefined,
|
||||||
|
group_by: undefined,
|
||||||
|
id: undefined,
|
||||||
metric: 'conversations_count',
|
metric: 'conversations_count',
|
||||||
since: 1621103400,
|
since: 1621103400,
|
||||||
until: 1621621800,
|
until: 1621621800,
|
||||||
type: 'account',
|
type: 'account',
|
||||||
timezone_offset: -0,
|
timezone_offset: timezoneOffset(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -59,13 +65,70 @@ describe('#Reports API', () => {
|
|||||||
group_by: undefined,
|
group_by: undefined,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
since: 1621103400,
|
since: 1621103400,
|
||||||
timezone_offset: -0,
|
timezone_offset: timezoneOffset(),
|
||||||
type: 'account',
|
type: 'account',
|
||||||
until: 1621621800,
|
until: 1621621800,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#getDrilldown', () => {
|
||||||
|
reportsAPI.getDrilldown({
|
||||||
|
metric: 'incoming_messages_count',
|
||||||
|
bucketTimestamp: 1621103400,
|
||||||
|
from: 1621103400,
|
||||||
|
to: 1621621800,
|
||||||
|
type: 'inbox',
|
||||||
|
id: 1,
|
||||||
|
groupBy: 'day',
|
||||||
|
businessHours: false,
|
||||||
|
page: 2,
|
||||||
|
perPage: 25,
|
||||||
|
});
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||||
|
params: {
|
||||||
|
metric: 'incoming_messages_count',
|
||||||
|
bucket_timestamp: 1621103400,
|
||||||
|
since: 1621103400,
|
||||||
|
until: 1621621800,
|
||||||
|
type: 'inbox',
|
||||||
|
id: 1,
|
||||||
|
group_by: 'day',
|
||||||
|
business_hours: false,
|
||||||
|
timezone_offset: timezoneOffset(),
|
||||||
|
page: 2,
|
||||||
|
per_page: 25,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#getDrilldown with abort signal', () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
reportsAPI.getDrilldown({
|
||||||
|
metric: 'incoming_messages_count',
|
||||||
|
bucketTimestamp: 1621103400,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axiosMock.get).toHaveBeenCalledWith('/api/v2/reports/drilldown', {
|
||||||
|
params: {
|
||||||
|
metric: 'incoming_messages_count',
|
||||||
|
bucket_timestamp: 1621103400,
|
||||||
|
since: undefined,
|
||||||
|
until: undefined,
|
||||||
|
type: 'account',
|
||||||
|
id: undefined,
|
||||||
|
group_by: undefined,
|
||||||
|
business_hours: undefined,
|
||||||
|
timezone_offset: timezoneOffset(),
|
||||||
|
page: undefined,
|
||||||
|
per_page: undefined,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('#getAgentReports', () => {
|
it('#getAgentReports', () => {
|
||||||
reportsAPI.getAgentReports({
|
reportsAPI.getAgentReports({
|
||||||
from: 1621103400,
|
from: 1621103400,
|
||||||
|
|||||||
+7
-1
@@ -16,6 +16,10 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
contact: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -49,7 +53,9 @@ const unreadMessagesCount = computed(() => {
|
|||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
return (
|
return (
|
||||||
slaCardLabelRef.value?.hasSlaThreshold && props.conversation?.slaPolicyId
|
!props.contact?.blocked &&
|
||||||
|
slaCardLabelRef.value?.hasSlaThreshold &&
|
||||||
|
props.conversation?.appliedSla?.id
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
@@ -126,6 +126,7 @@ const onCardClick = e => {
|
|||||||
v-show="!showMessagePreviewWithoutMeta"
|
v-show="!showMessagePreviewWithoutMeta"
|
||||||
ref="cardMessagePreviewWithMetaRef"
|
ref="cardMessagePreviewWithMetaRef"
|
||||||
:conversation="conversation"
|
:conversation="conversation"
|
||||||
|
:contact="contact"
|
||||||
:account-labels="accountLabels"
|
:account-labels="accountLabels"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-1
@@ -51,7 +51,9 @@ const unreadCount = computed(() => props.chat.unread_count);
|
|||||||
const slaCardLabel = useTemplateRef('slaCardLabel');
|
const slaCardLabel = useTemplateRef('slaCardLabel');
|
||||||
|
|
||||||
const hasSlaPolicyId = computed(
|
const hasSlaPolicyId = computed(
|
||||||
() => props.chat?.sla_policy_id || slaCardLabel.value?.hasSlaThreshold
|
() =>
|
||||||
|
!props.currentContact?.blocked &&
|
||||||
|
(props.chat?.applied_sla?.id || slaCardLabel.value?.hasSlaThreshold)
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectedModel = computed({
|
const selectedModel = computed({
|
||||||
|
|||||||
+4
-11
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
conversation: {
|
conversation: {
|
||||||
@@ -19,16 +19,8 @@ const slaStatus = ref({
|
|||||||
icon: null,
|
icon: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Remove this once we update the helper from utils
|
|
||||||
// https://github.com/chatwoot/utils/blob/main/src/sla.ts#L73
|
|
||||||
const convertObjectCamelCaseToSnakeCase = object => {
|
|
||||||
return Object.keys(object).reduce((acc, key) => {
|
|
||||||
acc[key.replace(/([A-Z])/g, '_$1').toLowerCase()] = object[key];
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
const appliedSLA = computed(() => props.conversation?.appliedSla);
|
||||||
|
const slaEvents = computed(() => props.conversation?.slaEvents);
|
||||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||||
|
|
||||||
const hasSlaThreshold = computed(() => {
|
const hasSlaThreshold = computed(() => {
|
||||||
@@ -41,8 +33,9 @@ const slaStatusText = computed(() => {
|
|||||||
|
|
||||||
const updateSlaStatus = () => {
|
const updateSlaStatus = () => {
|
||||||
slaStatus.value = evaluateSLAStatus({
|
slaStatus.value = evaluateSLAStatus({
|
||||||
appliedSla: convertObjectCamelCaseToSnakeCase(appliedSLA.value || {}),
|
appliedSla: appliedSLA.value || {},
|
||||||
chat: props.conversation,
|
chat: props.conversation,
|
||||||
|
slaEvents: slaEvents.value || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -20,6 +20,7 @@ const ICON_MAP = {
|
|||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_MAP = {
|
const COLOR_MAP = {
|
||||||
@@ -28,13 +29,18 @@ const COLOR_MAP = {
|
|||||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOutbound = computed(
|
const isOutbound = computed(
|
||||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
);
|
);
|
||||||
const isFailed = computed(() =>
|
const isFailed = computed(() =>
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
[
|
||||||
|
VOICE_CALL_STATUS.NO_ANSWER,
|
||||||
|
VOICE_CALL_STATUS.FAILED,
|
||||||
|
VOICE_CALL_STATUS.REJECTED,
|
||||||
|
].includes(props.status)
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const labelKey = computed(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Label from 'dashboard/components-next/label/Label.vue';
|
import Label from 'dashboard/components-next/label/Label.vue';
|
||||||
@@ -27,6 +27,7 @@ defineOptions({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const appliedSLA = computed(() => props.chat?.applied_sla);
|
const appliedSLA = computed(() => props.chat?.applied_sla);
|
||||||
|
const slaEvents = computed(() => props.chat?.sla_events);
|
||||||
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
const hasSlaThreshold = computed(() => slaStatus.value?.threshold);
|
||||||
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
const isSlaMissed = computed(() => slaStatus.value?.isSlaMissed);
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const updateSlaStatus = () => {
|
|||||||
slaStatus.value = evaluateSLAStatus({
|
slaStatus.value = evaluateSLAStatus({
|
||||||
appliedSla: appliedSLA.value || {},
|
appliedSla: appliedSLA.value || {},
|
||||||
chat: props.chat,
|
chat: props.chat,
|
||||||
|
slaEvents: slaEvents.value || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+378
@@ -0,0 +1,378 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||||
|
import { useEventListener, onKeyStroke, useRafFn } from '@vueuse/core';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
|
||||||
|
// Reorderable list with cross-page drag. It must stay mounted during a page
|
||||||
|
// fetch — hide the list spinner while `dragging`, or the held item is lost.
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, required: true },
|
||||||
|
itemKey: { type: String, default: 'id' },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
currentPage: { type: Number, default: 1 },
|
||||||
|
totalPages: { type: Number, default: 1 },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['reorder', 'navigatePage', 'dragging']);
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD = 5;
|
||||||
|
const EDGE_BAND = 72; // px from a side that arms a page turn
|
||||||
|
const AUTO_PAGE_DELAY = 600; // ms hovering an edge before it flips
|
||||||
|
const SCROLL_BAND = 60; // px from top/bottom that autoscrolls the list
|
||||||
|
const SCROLL_STEP = 10; // px scrolled per frame while parked at an edge
|
||||||
|
const PILL = 36; // px, the page-turn arrow button on each edge
|
||||||
|
|
||||||
|
const root = ref(null);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
const dragged = ref(null);
|
||||||
|
const pointer = ref({ x: 0, y: 0 });
|
||||||
|
const insertBefore = ref(null); // key the gap sits before, null = end of list
|
||||||
|
const activeEdge = ref(null);
|
||||||
|
const bounds = ref({ left: 0, right: 0, top: 0, bottom: 0 }); // visible list rect
|
||||||
|
const grab = ref({ dx: 0, dy: 0 }); // cursor offset inside the grabbed card
|
||||||
|
const dragHeight = ref(0);
|
||||||
|
|
||||||
|
let press = null; // pending press, before it becomes a drag
|
||||||
|
let dwell = null; // timer that flips the page after hovering an edge
|
||||||
|
let paging = false; // waiting for a flipped page to load
|
||||||
|
let scroller = null; // scrollable ancestor, found when a drag begins
|
||||||
|
let scrollDir = 0; // -1 up, +1 down, 0 idle
|
||||||
|
let sourcePage = 1; // page the drag started on, to resolve cross-page end-drops
|
||||||
|
|
||||||
|
const keyOf = item => String(item?.[props.itemKey]);
|
||||||
|
const others = item => props.items.filter(o => keyOf(o) !== keyOf(item));
|
||||||
|
const isRTL = () =>
|
||||||
|
document.querySelector('#app[dir]')?.getAttribute('dir') === 'rtl';
|
||||||
|
const canPage = dir =>
|
||||||
|
dir === 'next' ? props.currentPage < props.totalPages : props.currentPage > 1;
|
||||||
|
|
||||||
|
// Key of the item after `key` in `list`, or null when it is the last one.
|
||||||
|
const keyAfter = (list, key) => {
|
||||||
|
const next = list[list.findIndex(o => keyOf(o) === key) + 1];
|
||||||
|
return next ? keyOf(next) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const edges = computed(() =>
|
||||||
|
['prev', 'next'].filter(canPage).map(dir => {
|
||||||
|
const onLeft = isRTL() ? dir === 'next' : dir === 'prev';
|
||||||
|
return {
|
||||||
|
dir,
|
||||||
|
onLeft,
|
||||||
|
icon: onLeft ? 'i-lucide-chevrons-left' : 'i-lucide-chevrons-right',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cursor overlay: an edge glow band, a page-turn arrow per edge, and a ghost of
|
||||||
|
// the dragged row that follows the cursor and shrinks when aimed at an edge.
|
||||||
|
const EDGE_BAND_W = 80; // px width of the glow band on each pageable edge
|
||||||
|
const bandStyle = onLeft => {
|
||||||
|
const x = onLeft ? bounds.value.left : bounds.value.right - EDGE_BAND_W;
|
||||||
|
return {
|
||||||
|
width: `${EDGE_BAND_W}px`,
|
||||||
|
height: `${bounds.value.bottom - bounds.value.top}px`,
|
||||||
|
transform: `translate(${x}px, ${bounds.value.top}px)`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const pillStyle = onLeft => {
|
||||||
|
const x = onLeft ? bounds.value.left + 12 : bounds.value.right - PILL - 12;
|
||||||
|
return { transform: `translate(${x}px, ${pointer.value.y - PILL / 2}px)` };
|
||||||
|
};
|
||||||
|
const ghostStyle = computed(() => ({
|
||||||
|
width: `${bounds.value.right - bounds.value.left}px`,
|
||||||
|
transform: `translate(${pointer.value.x - grab.value.dx}px, ${pointer.value.y - grab.value.dy}px)`,
|
||||||
|
}));
|
||||||
|
const scaleStyle = computed(() => ({
|
||||||
|
transformOrigin: `${grab.value.dx}px ${grab.value.dy}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The list as shown mid-drag: the dragged row slotted into the gap at `insertBefore`.
|
||||||
|
const displayItems = computed(() => {
|
||||||
|
if (!isDragging.value || !dragged.value) return props.items;
|
||||||
|
const rest = others(dragged.value);
|
||||||
|
const at = rest.findIndex(o => keyOf(o) === insertBefore.value);
|
||||||
|
rest.splice(at === -1 ? rest.length : at, 0, dragged.value);
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move the gap when the cursor crosses the midpoint of the card it is over.
|
||||||
|
const aim = () => {
|
||||||
|
const card = document
|
||||||
|
.elementFromPoint(pointer.value.x, pointer.value.y)
|
||||||
|
?.closest('[data-drag-id]');
|
||||||
|
const key = card?.dataset.dragId;
|
||||||
|
if (!key || key === keyOf(dragged.value)) return;
|
||||||
|
|
||||||
|
const { top, height } = card.getBoundingClientRect();
|
||||||
|
const above = pointer.value.y < top + height / 2;
|
||||||
|
insertBefore.value = above ? key : keyAfter(others(dragged.value), key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const flip = dir => {
|
||||||
|
if (paging || !isDragging.value) return;
|
||||||
|
paging = true;
|
||||||
|
emit('navigatePage', props.currentPage + (dir === 'next' ? 1 : -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const aimEdge = x => {
|
||||||
|
const rect = root.value?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
// Clip the list rect to the scroll viewport so the glow band and its top/bottom
|
||||||
|
// fade always sit on the visible edges, not the far ends of the full content.
|
||||||
|
const view = scroller?.getBoundingClientRect();
|
||||||
|
const viewTop = Math.max(view?.top ?? 0, 0);
|
||||||
|
const viewBottom = Math.min(
|
||||||
|
view?.bottom ?? window.innerHeight,
|
||||||
|
window.innerHeight
|
||||||
|
);
|
||||||
|
bounds.value = {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
top: Math.max(rect.top, viewTop),
|
||||||
|
bottom: Math.min(rect.bottom, viewBottom),
|
||||||
|
};
|
||||||
|
|
||||||
|
let dir = null;
|
||||||
|
if (x <= rect.left + EDGE_BAND) dir = isRTL() ? 'next' : 'prev';
|
||||||
|
else if (x >= rect.right - EDGE_BAND) dir = isRTL() ? 'prev' : 'next';
|
||||||
|
if (dir && !canPage(dir)) dir = null;
|
||||||
|
if (dir === activeEdge.value) return;
|
||||||
|
|
||||||
|
activeEdge.value = dir;
|
||||||
|
clearTimeout(dwell);
|
||||||
|
if (dir) dwell = setTimeout(() => flip(dir), AUTO_PAGE_DELAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nearest scrollable ancestor, so a drag can reach rows that are off-screen.
|
||||||
|
const scrollParent = () => {
|
||||||
|
let el = root.value?.parentElement;
|
||||||
|
while (el) {
|
||||||
|
const { overflowY } = getComputedStyle(el);
|
||||||
|
if (overflowY === 'auto' || overflowY === 'scroll') return el;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// While parked at the top/bottom edge, keep scrolling and re-aim as rows slide by.
|
||||||
|
const { pause: pauseScroll, resume: resumeScroll } = useRafFn(
|
||||||
|
() => {
|
||||||
|
if (!scroller || !scrollDir) return;
|
||||||
|
scroller.scrollTop += scrollDir * SCROLL_STEP;
|
||||||
|
aim();
|
||||||
|
aimEdge(pointer.value.x);
|
||||||
|
},
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAutoScroll = y => {
|
||||||
|
if (!scroller) return;
|
||||||
|
const rect = scroller.getBoundingClientRect();
|
||||||
|
const atTop = scroller.scrollTop <= 0;
|
||||||
|
const atBottom =
|
||||||
|
scroller.scrollTop >= scroller.scrollHeight - scroller.clientHeight;
|
||||||
|
if (y < rect.top + SCROLL_BAND && !atTop) scrollDir = -1;
|
||||||
|
else if (y > rect.bottom - SCROLL_BAND && !atBottom) scrollDir = 1;
|
||||||
|
else scrollDir = 0;
|
||||||
|
if (scrollDir) resumeScroll();
|
||||||
|
else pauseScroll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
isDragging.value = false;
|
||||||
|
dragged.value = null;
|
||||||
|
insertBefore.value = null;
|
||||||
|
activeEdge.value = null;
|
||||||
|
clearTimeout(dwell);
|
||||||
|
paging = false;
|
||||||
|
scrollDir = 0;
|
||||||
|
scroller = null;
|
||||||
|
pauseScroll();
|
||||||
|
document.body.classList.remove('select-none');
|
||||||
|
emit('dragging', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Positions sit on a gap-of-10 grid; a midpoint (±5 at the ends) slots between two rows.
|
||||||
|
// Cross-page end-drops are the tricky case: moving an item off its source page pulls the
|
||||||
|
// target page's boundary row into the vacated slot. So at the leading edge after moving
|
||||||
|
// DOWN (or the trailing edge after moving UP) we land between the two boundary rows, or
|
||||||
|
// the item sorts onto the adjacent page and vanishes from view.
|
||||||
|
const drop = (item, rawBefore) => {
|
||||||
|
const pos = o => o?.position || 0;
|
||||||
|
const mid = (a, b) => Math.floor((pos(a) + pos(b)) / 2);
|
||||||
|
const rest = others(item);
|
||||||
|
// No other rows to position against — e.g. the lone article on a page dropped
|
||||||
|
// without crossing to another page. Leave the order untouched.
|
||||||
|
if (!rest.length) return;
|
||||||
|
// A page flip can leave the aimed key pointing at a row that is no longer on
|
||||||
|
// this page. Like displayItems, resolve an unknown key to null (end of list),
|
||||||
|
// so what the user sees and what we save agree.
|
||||||
|
const before = rest.some(o => keyOf(o) === rawBefore) ? rawBefore : null;
|
||||||
|
const movedDown = props.currentPage > sourcePage;
|
||||||
|
const movedUp = props.currentPage < sourcePage;
|
||||||
|
|
||||||
|
let position;
|
||||||
|
if (before === null) {
|
||||||
|
position =
|
||||||
|
movedUp && rest.length >= 2
|
||||||
|
? mid(rest.at(-2), rest.at(-1))
|
||||||
|
: pos(rest.at(-1)) + 5;
|
||||||
|
} else {
|
||||||
|
const i = rest.findIndex(o => keyOf(o) === before);
|
||||||
|
if (i > 0) {
|
||||||
|
position = mid(rest[i - 1], rest[i]);
|
||||||
|
} else if (movedDown) {
|
||||||
|
// Top-of-page after moving down: the old first row slid up into the source
|
||||||
|
// page's gap, so land just after it to stay this page's first.
|
||||||
|
position = rest.length >= 2 ? mid(rest[0], rest[1]) : pos(rest[0]) + 5;
|
||||||
|
} else {
|
||||||
|
position = pos(rest[0]) - 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit('reorder', { [item[props.itemKey]]: position });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = () => {
|
||||||
|
isDragging.value = true;
|
||||||
|
dragged.value = press.item;
|
||||||
|
grab.value = { dx: press.dx, dy: press.dy };
|
||||||
|
dragHeight.value = press.h;
|
||||||
|
insertBefore.value = keyAfter(props.items, keyOf(press.item));
|
||||||
|
sourcePage = props.currentPage;
|
||||||
|
scroller = scrollParent();
|
||||||
|
document.body.classList.add('select-none');
|
||||||
|
emit('dragging', true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (item, e) => {
|
||||||
|
if (e.button !== 0 || props.disabled) return;
|
||||||
|
if (e.target.closest('button, a, input, [role="button"]')) return;
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
press = {
|
||||||
|
item,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
dx: e.clientX - rect.left,
|
||||||
|
dy: e.clientY - rect.top,
|
||||||
|
h: rect.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener(window, 'pointermove', e => {
|
||||||
|
if (!press) return;
|
||||||
|
if (!isDragging.value) {
|
||||||
|
const moved = Math.hypot(e.clientX - press.x, e.clientY - press.y);
|
||||||
|
if (moved < DRAG_THRESHOLD) return;
|
||||||
|
startDrag();
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
pointer.value = { x: e.clientX, y: e.clientY };
|
||||||
|
aim();
|
||||||
|
aimEdge(e.clientX);
|
||||||
|
updateAutoScroll(e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEventListener(window, 'pointerup', () => {
|
||||||
|
if (!press) return;
|
||||||
|
press = null;
|
||||||
|
if (!isDragging.value) return; // a press without movement is a click
|
||||||
|
const item = dragged.value;
|
||||||
|
const before = insertBefore.value;
|
||||||
|
const flipping = paging;
|
||||||
|
reset();
|
||||||
|
if (!flipping) drop(item, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the flipped page loads, re-aim under the held cursor so a parked edge keeps flipping.
|
||||||
|
watch(
|
||||||
|
() => props.items,
|
||||||
|
() => {
|
||||||
|
if (!isDragging.value || !paging) return;
|
||||||
|
paging = false;
|
||||||
|
nextTick(() => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
aim();
|
||||||
|
activeEdge.value = null;
|
||||||
|
aimEdge(pointer.value.x);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onKeyStroke('Escape', () => {
|
||||||
|
if (!isDragging.value) return;
|
||||||
|
press = null;
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimeout(dwell);
|
||||||
|
document.body.classList.remove('select-none');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="root" class="relative w-full h-full">
|
||||||
|
<ul class="w-full h-full space-y-4">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in displayItems"
|
||||||
|
:key="keyOf(item)"
|
||||||
|
:data-drag-id="keyOf(item)"
|
||||||
|
class="relative list-none"
|
||||||
|
:class="{ 'cursor-grab': !disabled && !isDragging }"
|
||||||
|
@pointerdown="onPointerDown(item, $event)"
|
||||||
|
@dragstart.prevent
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isDragging && keyOf(item) === keyOf(dragged)"
|
||||||
|
:style="{ height: `${dragHeight}px` }"
|
||||||
|
class="border-2 border-dashed rounded-2xl border-n-brand/50 bg-n-brand/5"
|
||||||
|
/>
|
||||||
|
<slot v-else name="item" :item="item" :index="index" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<Teleport v-if="isDragging" to="body">
|
||||||
|
<div
|
||||||
|
v-for="edge in edges"
|
||||||
|
:key="`band-${edge.dir}`"
|
||||||
|
:style="bandStyle(edge.onLeft)"
|
||||||
|
class="fixed top-0 left-0 z-40 pointer-events-none from-n-brand/15 to-transparent transition-opacity duration-200 [mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)] [-webkit-mask-image:linear-gradient(to_bottom,transparent,#000_56px,#000_calc(100%_-_56px),transparent)]"
|
||||||
|
:class="[
|
||||||
|
edge.onLeft ? 'bg-gradient-to-r' : 'bg-gradient-to-l',
|
||||||
|
activeEdge === edge.dir ? 'opacity-100' : 'opacity-0',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="edge in edges"
|
||||||
|
:key="edge.dir"
|
||||||
|
:style="pillStyle(edge.onLeft)"
|
||||||
|
class="fixed top-0 left-0 z-50 flex items-center justify-center transition-all duration-150 border rounded-full pointer-events-none size-9 backdrop-blur-sm"
|
||||||
|
:class="
|
||||||
|
activeEdge === edge.dir
|
||||||
|
? 'scale-110 border-n-brand bg-n-brand/20 text-n-brand shadow-md'
|
||||||
|
: 'opacity-70 border-n-weak/60 bg-n-solid-1/70 text-n-slate-10'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
:icon="edge.icon"
|
||||||
|
class="size-4"
|
||||||
|
:class="activeEdge === edge.dir && 'animate-pulse'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="dragged"
|
||||||
|
:style="ghostStyle"
|
||||||
|
class="fixed top-0 left-0 z-50 pointer-events-none select-none"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:style="scaleStyle"
|
||||||
|
class="transition-transform duration-150 shadow-2xl rounded-2xl"
|
||||||
|
:class="{ 'scale-50': activeEdge }"
|
||||||
|
>
|
||||||
|
<slot name="ghost" :item="dragged" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+222
@@ -0,0 +1,222 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { h, nextTick } from 'vue';
|
||||||
|
import DraggableReorderList from '../DraggableReorderList.vue';
|
||||||
|
|
||||||
|
// The component is pointer-driven, so we drive it through real pointer events on
|
||||||
|
// window while mocking the layout APIs jsdom does not implement: elementFromPoint
|
||||||
|
// (which card is under the cursor) and getBoundingClientRect (its geometry).
|
||||||
|
const elementAtPoint = { current: null };
|
||||||
|
|
||||||
|
const move = (clientX, clientY) =>
|
||||||
|
window.dispatchEvent(new MouseEvent('pointermove', { clientX, clientY }));
|
||||||
|
const release = () => window.dispatchEvent(new MouseEvent('pointerup'));
|
||||||
|
|
||||||
|
// Stack the rows 50px apart, each 40px tall, inside a 500px-wide list.
|
||||||
|
const stubGeometry = wrapper => {
|
||||||
|
wrapper.element.getBoundingClientRect = () => ({
|
||||||
|
left: 0,
|
||||||
|
right: 500,
|
||||||
|
top: 0,
|
||||||
|
bottom: 600,
|
||||||
|
});
|
||||||
|
wrapper.findAll('[data-drag-id]').forEach((li, index) => {
|
||||||
|
const top = index * 50;
|
||||||
|
li.element.getBoundingClientRect = () => ({
|
||||||
|
top,
|
||||||
|
height: 40,
|
||||||
|
bottom: top + 40,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountList = (props = {}) =>
|
||||||
|
mount(DraggableReorderList, {
|
||||||
|
props: { items: [], ...props },
|
||||||
|
slots: {
|
||||||
|
item: scope => h('div', { class: 'card' }, scope.item.title),
|
||||||
|
ghost: scope => h('div', { class: 'ghost' }, scope.item.title),
|
||||||
|
},
|
||||||
|
global: { stubs: { Icon: true, teleport: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DraggableReorderList', () => {
|
||||||
|
let wrapper;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
elementAtPoint.current = null;
|
||||||
|
document.elementFromPoint = vi.fn(() => elementAtPoint.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
const startDragging = async id => {
|
||||||
|
stubGeometry(wrapper);
|
||||||
|
wrapper.find(`[data-drag-id="${id}"]`).element.dispatchEvent(
|
||||||
|
new MouseEvent('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 20,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders each item through the item slot', () => {
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha' },
|
||||||
|
{ id: 2, title: 'Beta' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = wrapper.findAll('.card');
|
||||||
|
expect(cards).toHaveLength(2);
|
||||||
|
expect(cards[0].text()).toBe('Alpha');
|
||||||
|
expect(wrapper.find('[data-drag-id="1"]').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('[data-drag-id="2"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a grab affordance only when enabled', () => {
|
||||||
|
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }] });
|
||||||
|
expect(wrapper.find('[data-drag-id="1"]').classes()).toContain(
|
||||||
|
'cursor-grab'
|
||||||
|
);
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
wrapper = mountList({ items: [{ id: 1, title: 'Alpha' }], disabled: true });
|
||||||
|
expect(wrapper.find('[data-drag-id="1"]').classes()).not.toContain(
|
||||||
|
'cursor-grab'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not start a drag when disabled', async () => {
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha' },
|
||||||
|
{ id: 2, title: 'Beta' },
|
||||||
|
],
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
await startDragging(1);
|
||||||
|
move(250, 200);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.emitted('dragging')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits dragging true then false across a drag', async () => {
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha' },
|
||||||
|
{ id: 2, title: 'Beta' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await startDragging(1);
|
||||||
|
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||||
|
move(250, 60);
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.emitted('dragging')[0]).toEqual([true]);
|
||||||
|
|
||||||
|
release();
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.emitted('dragging')[1]).toEqual([false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits the midpoint position when dropped between two rows', async () => {
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha', position: 10 },
|
||||||
|
{ id: 2, title: 'Beta', position: 20 },
|
||||||
|
{ id: 3, title: 'Gamma', position: 30 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await startDragging(1);
|
||||||
|
|
||||||
|
// Hover the lower half of Beta (top 50, height 40 → midpoint 70) so the gap
|
||||||
|
// sits before Gamma; dropping there lands halfway between Beta and Gamma.
|
||||||
|
elementAtPoint.current = wrapper.find('[data-drag-id="2"]').element;
|
||||||
|
move(250, 85);
|
||||||
|
await nextTick();
|
||||||
|
release();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.emitted('reorder')[0][0]).toEqual({ 1: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reorder when the only row on a page is dropped in place', async () => {
|
||||||
|
// P1: dragging the lone article on a later page and releasing without
|
||||||
|
// crossing to another page must be a no-op, not move it to the top.
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [{ id: 5, title: 'Solo', position: 260 }],
|
||||||
|
currentPage: 2,
|
||||||
|
totalPages: 2,
|
||||||
|
});
|
||||||
|
await startDragging(5);
|
||||||
|
move(250, 300);
|
||||||
|
await nextTick();
|
||||||
|
release();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.emitted('dragging')).toEqual([[true], [false]]);
|
||||||
|
expect(wrapper.emitted('reorder')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns the page after dwelling on a pageable edge', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha', position: 10 },
|
||||||
|
{ id: 2, title: 'Beta', position: 20 },
|
||||||
|
],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 2,
|
||||||
|
});
|
||||||
|
await startDragging(1);
|
||||||
|
|
||||||
|
// Drag to the right edge over blank space (no card) and hold.
|
||||||
|
elementAtPoint.current = null;
|
||||||
|
move(490, 20);
|
||||||
|
await nextTick();
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('navigatePage')[0]).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can still turn pages after releasing during a pending flip', async () => {
|
||||||
|
// Releasing while a flip fetch is in flight must clear paging state, or every
|
||||||
|
// later drag would be stuck unable to navigate.
|
||||||
|
vi.useFakeTimers();
|
||||||
|
wrapper = mountList({
|
||||||
|
items: [
|
||||||
|
{ id: 1, title: 'Alpha', position: 10 },
|
||||||
|
{ id: 2, title: 'Beta', position: 20 },
|
||||||
|
],
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First drag: park at the edge to start a flip, then release before the new
|
||||||
|
// page arrives (items never change here).
|
||||||
|
await startDragging(1);
|
||||||
|
elementAtPoint.current = null;
|
||||||
|
move(490, 20);
|
||||||
|
await nextTick();
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
release();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Second drag must be able to flip again.
|
||||||
|
await startDragging(1);
|
||||||
|
elementAtPoint.current = null;
|
||||||
|
move(490, 20);
|
||||||
|
await nextTick();
|
||||||
|
vi.advanceTimersByTime(600);
|
||||||
|
|
||||||
|
expect(wrapper.emitted('navigatePage')).toEqual([[2], [2]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+68
-72
@@ -1,6 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import Draggable from 'vuedraggable';
|
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@@ -10,6 +9,7 @@ import { getArticleStatus } from 'dashboard/helper/portalHelper.js';
|
|||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
import ArticleCard from 'dashboard/components-next/HelpCenter/ArticleCard/ArticleCard.vue';
|
||||||
|
import DraggableReorderList from 'dashboard/components-next/DraggableReorderList/DraggableReorderList.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
articles: {
|
articles: {
|
||||||
@@ -28,9 +28,22 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
currentPage: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
|
totalPages: {
|
||||||
|
type: Number,
|
||||||
|
default: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['translateArticle', 'toggleSelect']);
|
const emit = defineEmits([
|
||||||
|
'translateArticle',
|
||||||
|
'toggleSelect',
|
||||||
|
'navigatePage',
|
||||||
|
'dragging',
|
||||||
|
]);
|
||||||
|
|
||||||
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
const { ARTICLE_STATUS_TYPES } = wootConstants;
|
||||||
|
|
||||||
@@ -39,14 +52,14 @@ const route = useRoute();
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const localArticles = ref(props.articles);
|
|
||||||
const hoveredArticleId = ref(null);
|
const hoveredArticleId = ref(null);
|
||||||
|
|
||||||
const dragEnabled = computed(() => {
|
const dragEnabled = computed(() => {
|
||||||
|
const canReorder = props.articles?.length > 1 || props.totalPages > 1;
|
||||||
return (
|
return (
|
||||||
props.isCategoryArticles &&
|
props.isCategoryArticles &&
|
||||||
!props.isSearching &&
|
!props.isSearching &&
|
||||||
localArticles.value?.length > 1 &&
|
canReorder &&
|
||||||
props.selectedArticleIds.size === 0
|
props.selectedArticleIds.size === 0
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -63,6 +76,10 @@ const handleCardHover = (isHovered, id) => {
|
|||||||
|
|
||||||
const getCategoryById = useMapGetter('categories/categoryById');
|
const getCategoryById = useMapGetter('categories/categoryById');
|
||||||
|
|
||||||
|
const getCategory = categoryId => {
|
||||||
|
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
||||||
|
};
|
||||||
|
|
||||||
const openArticle = id => {
|
const openArticle = id => {
|
||||||
const { tab, categorySlug, locale } = route.params;
|
const { tab, categorySlug, locale } = route.params;
|
||||||
if (props.isCategoryArticles) {
|
if (props.isCategoryArticles) {
|
||||||
@@ -83,38 +100,25 @@ const openArticle = id => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReorder = async reorderedGroup => {
|
const onReorder = async positionsHash => {
|
||||||
|
const [movedId] = Object.keys(positionsHash);
|
||||||
|
// A same-page reorder updates optimistically in the store, so it needs no
|
||||||
|
// refetch. Only a cross-page drop must refresh, to pull the moved article
|
||||||
|
// onto this page in its new spot.
|
||||||
|
const isCrossPage = !props.articles.some(
|
||||||
|
article => String(article.id) === movedId
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await store.dispatch('articles/reorder', {
|
await store.dispatch('articles/reorder', {
|
||||||
reorderedGroup,
|
reorderedGroup: positionsHash,
|
||||||
portalSlug: route.params.portalSlug,
|
portalSlug: route.params.portalSlug,
|
||||||
});
|
});
|
||||||
|
if (isCrossPage) emit('navigatePage', props.currentPage);
|
||||||
} catch {
|
} catch {
|
||||||
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
useAlert(t('HELP_CENTER.REORDER_ARTICLE.API.ERROR_MESSAGE'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = () => {
|
|
||||||
// Collect and sort existing positions, falling back to index+1 for null/0 values
|
|
||||||
const sortedArticlePositions = localArticles.value
|
|
||||||
.map((article, index) => article.position || index + 1)
|
|
||||||
.sort((a, b) => a - b);
|
|
||||||
|
|
||||||
const orderedArticles = localArticles.value.map(article => article.id);
|
|
||||||
|
|
||||||
// Create a map of article IDs to their new positions
|
|
||||||
const reorderedGroup = orderedArticles.reduce((obj, key, index) => {
|
|
||||||
obj[key] = sortedArticlePositions[index];
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
onReorder(reorderedGroup);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategory = categoryId => {
|
|
||||||
return getCategoryById.value(categoryId) || { name: '', icon: '' };
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusMessage = (status, isSuccess) => {
|
const getStatusMessage = (status, isSuccess) => {
|
||||||
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
const messageType = isSuccess ? 'SUCCESS' : 'ERROR';
|
||||||
const statusMap = {
|
const statusMap = {
|
||||||
@@ -184,54 +188,46 @@ const updateArticle = ({ action, value, id }) => {
|
|||||||
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
const status = action !== 'delete' ? getArticleStatus(value) : null;
|
||||||
handleArticleAction(action, { status, id });
|
handleArticleAction(action, { status, id });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for changes in the articles prop and update the localArticles ref
|
|
||||||
watch(
|
|
||||||
() => props.articles,
|
|
||||||
newArticles => {
|
|
||||||
localArticles.value = newArticles;
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Draggable
|
<DraggableReorderList
|
||||||
v-model="localArticles"
|
:items="articles"
|
||||||
:disabled="!dragEnabled"
|
:disabled="!dragEnabled"
|
||||||
item-key="id"
|
:current-page="currentPage"
|
||||||
tag="ul"
|
:total-pages="totalPages"
|
||||||
ghost-class="article-ghost-class"
|
@reorder="onReorder"
|
||||||
class="w-full h-full space-y-4"
|
@navigate-page="page => emit('navigatePage', page)"
|
||||||
@end="onDragEnd"
|
@dragging="value => emit('dragging', value)"
|
||||||
>
|
>
|
||||||
<template #item="{ element }">
|
<template #item="{ item }">
|
||||||
<li class="list-none rounded-2xl">
|
<ArticleCard
|
||||||
<ArticleCard
|
:id="item.id"
|
||||||
:id="element.id"
|
:title="item.title"
|
||||||
:key="element.id"
|
:status="item.status"
|
||||||
:title="element.title"
|
:author="item.author"
|
||||||
:status="element.status"
|
:category="getCategory(item.category.id)"
|
||||||
:author="element.author"
|
:views="item.views || 0"
|
||||||
:category="getCategory(element.category.id)"
|
:updated-at="item.updatedAt"
|
||||||
:views="element.views || 0"
|
:is-selected="selectedArticleIds.has(item.id)"
|
||||||
:updated-at="element.updatedAt"
|
selectable
|
||||||
:is-selected="selectedArticleIds.has(element.id)"
|
:show-selection-control="shouldShowSelectionControl(item.id)"
|
||||||
selectable
|
@open-article="openArticle"
|
||||||
:show-selection-control="shouldShowSelectionControl(element.id)"
|
@article-action="updateArticle"
|
||||||
:class="{ 'cursor-grab': dragEnabled }"
|
@toggle-select="emit('toggleSelect', $event)"
|
||||||
@open-article="openArticle"
|
@hover="isHovered => handleCardHover(isHovered, item.id)"
|
||||||
@article-action="updateArticle"
|
/>
|
||||||
@toggle-select="emit('toggleSelect', $event)"
|
|
||||||
@hover="isHovered => handleCardHover(isHovered, element.id)"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</template>
|
</template>
|
||||||
</Draggable>
|
<template #ghost="{ item }">
|
||||||
|
<ArticleCard
|
||||||
|
:id="item.id"
|
||||||
|
:title="item.title"
|
||||||
|
:status="item.status"
|
||||||
|
:author="item.author"
|
||||||
|
:category="getCategory(item.category.id)"
|
||||||
|
:views="item.views || 0"
|
||||||
|
:updated-at="item.updatedAt"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</DraggableReorderList>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.article-ghost-class {
|
|
||||||
@apply opacity-50 bg-n-solid-1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+8
-1
@@ -70,6 +70,7 @@ const isFeatureEnabledonAccount = useMapGetter(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const selectedArticleIds = ref(new Set());
|
const selectedArticleIds = ref(new Set());
|
||||||
|
const isArticleDragging = ref(false);
|
||||||
const deleteConfirmDialogRef = ref(null);
|
const deleteConfirmDialogRef = ref(null);
|
||||||
const isCategoryMenuOpen = ref(false);
|
const isCategoryMenuOpen = ref(false);
|
||||||
const searchQuery = ref(route.query.search || '');
|
const searchQuery = ref(route.query.search || '');
|
||||||
@@ -148,6 +149,8 @@ const articlesCount = computed(() => {
|
|||||||
return Number(countMap[tab] || countMap['']);
|
return Number(countMap[tab] || countMap['']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(articlesCount.value / 25) || 1);
|
||||||
|
|
||||||
const showArticleHeaderControls = computed(
|
const showArticleHeaderControls = computed(
|
||||||
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
() => !props.isCategoryArticles && !isSwitchingPortal.value
|
||||||
);
|
);
|
||||||
@@ -343,7 +346,7 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div
|
<div
|
||||||
v-if="isLoading"
|
v-if="isLoading && !isArticleDragging"
|
||||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||||
>
|
>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
@@ -453,9 +456,13 @@ watch(
|
|||||||
:is-category-articles="isCategoryArticles"
|
:is-category-articles="isCategoryArticles"
|
||||||
:is-searching="isSearching"
|
:is-searching="isSearching"
|
||||||
:selected-article-ids="selectedArticleIds"
|
:selected-article-ids="selectedArticleIds"
|
||||||
|
:current-page="Number(meta.currentPage)"
|
||||||
|
:total-pages="totalPages"
|
||||||
class="relative z-0"
|
class="relative z-0"
|
||||||
@translate-article="handleTranslateArticle"
|
@translate-article="handleTranslateArticle"
|
||||||
@toggle-select="handleToggleSelect"
|
@toggle-select="handleToggleSelect"
|
||||||
|
@navigate-page="handlePageChange"
|
||||||
|
@dragging="isArticleDragging = $event"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<ArticleEmptyState
|
<ArticleEmptyState
|
||||||
|
|||||||
-23
@@ -29,7 +29,6 @@ const initialState = {
|
|||||||
handoffMessage: '',
|
handoffMessage: '',
|
||||||
resolutionMessage: '',
|
resolutionMessage: '',
|
||||||
instructions: '',
|
instructions: '',
|
||||||
temperature: 1,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@@ -57,7 +56,6 @@ const updateStateFromAssistant = assistant => {
|
|||||||
state.handoffMessage = config.handoff_message;
|
state.handoffMessage = config.handoff_message;
|
||||||
state.resolutionMessage = config.resolution_message;
|
state.resolutionMessage = config.resolution_message;
|
||||||
state.instructions = config.instructions;
|
state.instructions = config.instructions;
|
||||||
state.temperature = config.temperature || 1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSystemMessagesUpdate = async () => {
|
const handleSystemMessagesUpdate = async () => {
|
||||||
@@ -80,7 +78,6 @@ const handleSystemMessagesUpdate = async () => {
|
|||||||
...props.assistant.config,
|
...props.assistant.config,
|
||||||
handoff_message: state.handoffMessage,
|
handoff_message: state.handoffMessage,
|
||||||
resolution_message: state.resolutionMessage,
|
resolution_message: state.resolutionMessage,
|
||||||
temperature: state.temperature || 1,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -131,26 +128,6 @@ watch(
|
|||||||
class="z-0"
|
class="z-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<input
|
|
||||||
v-model="state.temperature"
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<span class="text-sm text-n-slate-12">{{ state.temperature }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-n-slate-11 italic">
|
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.DESCRIPTION') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ const selectEmoji = emoji => {
|
|||||||
<Button
|
<Button
|
||||||
v-if="showRemoveButton && value"
|
v-if="showRemoveButton && value"
|
||||||
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
|
||||||
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="slate"
|
color="slate"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const ICON_MAP = {
|
|||||||
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
[VOICE_CALL_STATUS.COMPLETED]: 'i-ph-phone-bold',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x-bold',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x-bold',
|
||||||
|
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x-bold',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -81,7 +82,11 @@ const isWhatsapp = computed(
|
|||||||
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
() => call.value?.provider === VOICE_CALL_PROVIDERS.WHATSAPP
|
||||||
);
|
);
|
||||||
const isFailed = computed(() =>
|
const isFailed = computed(() =>
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
[
|
||||||
|
VOICE_CALL_STATUS.NO_ANSWER,
|
||||||
|
VOICE_CALL_STATUS.FAILED,
|
||||||
|
VOICE_CALL_STATUS.REJECTED,
|
||||||
|
].includes(status.value)
|
||||||
);
|
);
|
||||||
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
const isMissedInbound = computed(() => isFailed.value && !isOutbound.value);
|
||||||
const endReason = computed(() => call.value?.endReason);
|
const endReason = computed(() => call.value?.endReason);
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export const VOICE_CALL_STATUS = {
|
|||||||
COMPLETED: 'completed',
|
COMPLETED: 'completed',
|
||||||
NO_ANSWER: 'no-answer',
|
NO_ANSWER: 'no-answer',
|
||||||
FAILED: 'failed',
|
FAILED: 'failed',
|
||||||
|
REJECTED: 'rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VOICE_CALL_DIRECTION = {
|
export const VOICE_CALL_DIRECTION = {
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ onMounted(() => {
|
|||||||
a {
|
a {
|
||||||
@apply p-4;
|
@apply p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ProseMirror a {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
createErrorMessage(error) {
|
||||||
|
const responseError = error?.response?.data?.error;
|
||||||
|
if (typeof responseError === 'string') return responseError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
responseError?.error?.message ||
|
||||||
|
responseError?.message ||
|
||||||
|
this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR')
|
||||||
|
);
|
||||||
|
},
|
||||||
async onClick() {
|
async onClick() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
try {
|
try {
|
||||||
await DyteAPI.createAMeeting(this.conversationId);
|
await DyteAPI.createAMeeting(this.conversationId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(this.$t('INTEGRATION_SETTINGS.DYTE.CREATE_ERROR'));
|
useAlert(this.createErrorMessage(error));
|
||||||
} finally {
|
} finally {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ const showMetaSection = computed(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
const hasSlaPolicyId = computed(
|
||||||
|
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
|
||||||
|
);
|
||||||
|
|
||||||
const showLabelsSection = computed(() => {
|
const showLabelsSection = computed(() => {
|
||||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ const hasMultipleInboxes = computed(
|
|||||||
() => store.getters['inboxes/getInboxes'].length > 1
|
() => store.getters['inboxes/getInboxes'].length > 1
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
const hasSlaPolicyId = computed(
|
||||||
|
() => props.chat?.applied_sla?.id && !currentContact.value?.blocked
|
||||||
|
);
|
||||||
|
|
||||||
const copyConversationId = async () => {
|
const copyConversationId = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const ICON_MAP = {
|
|||||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||||
|
[VOICE_CALL_STATUS.REJECTED]: 'i-ph-phone-x',
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLOR_MAP = {
|
const COLOR_MAP = {
|
||||||
@@ -29,13 +30,18 @@ const COLOR_MAP = {
|
|||||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||||
|
[VOICE_CALL_STATUS.REJECTED]: 'text-n-ruby-9',
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOutbound = computed(
|
const isOutbound = computed(
|
||||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||||
);
|
);
|
||||||
const isFailed = computed(() =>
|
const isFailed = computed(() =>
|
||||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
[
|
||||||
|
VOICE_CALL_STATUS.NO_ANSWER,
|
||||||
|
VOICE_CALL_STATUS.FAILED,
|
||||||
|
VOICE_CALL_STATUS.REJECTED,
|
||||||
|
].includes(props.status)
|
||||||
);
|
);
|
||||||
|
|
||||||
const labelKey = computed(() => {
|
const labelKey = computed(() => {
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { evaluateSLAStatus } from '@chatwoot/utils';
|
import { evaluateSLAStatus } from 'dashboard/helper/slaHelper';
|
||||||
import SLAPopoverCard from './SLAPopoverCard.vue';
|
import SLAPopoverCard from './SLAPopoverCard.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -61,6 +61,7 @@ const updateSlaStatus = () => {
|
|||||||
slaStatus.value = evaluateSLAStatus({
|
slaStatus.value = evaluateSLAStatus({
|
||||||
appliedSla: appliedSLA.value,
|
appliedSla: appliedSLA.value,
|
||||||
chat: props.chat,
|
chat: props.chat,
|
||||||
|
slaEvents: slaEvents.value || [],
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+60
@@ -0,0 +1,60 @@
|
|||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import ConversationCard from '../ConversationCard.vue';
|
||||||
|
|
||||||
|
const defaultChat = {
|
||||||
|
id: 1,
|
||||||
|
labels: [],
|
||||||
|
messages: [],
|
||||||
|
priority: null,
|
||||||
|
unread_count: 0,
|
||||||
|
timestamp: 1700000000,
|
||||||
|
created_at: 1700000000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mountComponent = (chat, currentContact = {}) =>
|
||||||
|
shallowMount(ConversationCard, {
|
||||||
|
props: {
|
||||||
|
chat: { ...defaultChat, ...chat },
|
||||||
|
currentContact: {
|
||||||
|
name: 'Jane Doe',
|
||||||
|
thumbnail: '',
|
||||||
|
availability_status: 'offline',
|
||||||
|
...currentContact,
|
||||||
|
},
|
||||||
|
inbox: { id: 1 },
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
'fluent-icon': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConversationCard', () => {
|
||||||
|
it('does not reserve the labels row when only a persisted SLA policy id is present', () => {
|
||||||
|
const wrapper = mountComponent({ sla_policy_id: 1, applied_sla: null });
|
||||||
|
|
||||||
|
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the labels row when an active applied SLA is present', () => {
|
||||||
|
const wrapper = mountComponent({
|
||||||
|
sla_policy_id: 1,
|
||||||
|
applied_sla: { id: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reserve the labels row when the contact is blocked', () => {
|
||||||
|
const wrapper = mountComponent(
|
||||||
|
{
|
||||||
|
sla_policy_id: 1,
|
||||||
|
applied_sla: { id: 1 },
|
||||||
|
},
|
||||||
|
{ blocked: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(wrapper.findComponent({ name: 'CardLabels' }).exists()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -161,6 +161,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => {
|
|||||||
return { conferenceSid: joinResponse?.conference_sid };
|
return { conferenceSid: joinResponse?.conference_sid };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
|
||||||
|
// 409 = the call already ended before accept landed (e.g. caller hung up mid-ring).
|
||||||
if (error?.response?.status === 409) {
|
if (error?.response?.status === 409) {
|
||||||
TwilioVoiceClient.endClientCall();
|
TwilioVoiceClient.endClientCall();
|
||||||
markDismissed(callSid);
|
markDismissed(callSid);
|
||||||
|
|||||||
@@ -163,6 +163,11 @@ export const FORMATTING = {
|
|||||||
nodes: [],
|
nodes: [],
|
||||||
menu: [],
|
menu: [],
|
||||||
},
|
},
|
||||||
|
'Context::NoToolbar': {
|
||||||
|
marks: ['strong', 'em', 'link'],
|
||||||
|
nodes: ['bulletList', 'orderedList'],
|
||||||
|
menu: [],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Editor menu options for Full Editor
|
// Editor menu options for Full Editor
|
||||||
@@ -183,6 +188,16 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
|||||||
'insertTable',
|
'insertTable',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// [text](url) -> "text: url" (drop label if it equals the URL). Keep serializer
|
||||||
|
// escapes; the re-parse renders them literally, unescaping would crash it.
|
||||||
|
const flattenLink = (_match, text, url) => {
|
||||||
|
const cleanUrl = url
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+["'(].*$/, '')
|
||||||
|
.replace(/^<|>$/g, '');
|
||||||
|
return text === cleanUrl ? cleanUrl : `${text}: ${cleanUrl}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Markdown formatting patterns for stripping unsupported formatting.
|
* Markdown formatting patterns for stripping unsupported formatting.
|
||||||
*
|
*
|
||||||
@@ -259,7 +274,12 @@ export const MARKDOWN_PATTERNS = [
|
|||||||
{
|
{
|
||||||
type: 'link', // PM: link
|
type: 'link', // PM: link
|
||||||
patterns: [
|
patterns: [
|
||||||
{ pattern: /\[([^\]]+)\]\([^)]+\)/g, replacement: '$1' }, // [text](url) -> text
|
// Escape-aware label + URL captures so a \] or \) can't cut the match
|
||||||
|
// short and leave link markup that crashes the re-parse.
|
||||||
|
{
|
||||||
|
pattern: /\[((?:\\.|[^\]\\])*)\]\(((?:\\.|[^)\\])*)\)/g,
|
||||||
|
replacement: flattenLink,
|
||||||
|
},
|
||||||
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
{ pattern: /<([a-zA-Z][a-zA-Z0-9+.-]*:[^\s>]+)>/g, replacement: '$1' }, // <https://...>, <mailto:...>, <tel:...>, <ftp://...>, etc
|
||||||
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
|
{ pattern: /<([^\s@]+@[^\s@>]+)>/g, replacement: '$1' }, // <user@example.com> -> user@example.com
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -162,4 +162,7 @@ export const SESSION_EVENTS = Object.freeze({
|
|||||||
export const ONBOARDING_EVENTS = Object.freeze({
|
export const ONBOARDING_EVENTS = Object.freeze({
|
||||||
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
|
||||||
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
|
||||||
|
INBOX_SETUP_VISITED: 'Onboarding: Inbox setup visited',
|
||||||
|
INBOX_SETUP_COMPLETED: 'Onboarding: Inbox setup completed',
|
||||||
|
INBOX_SETUP_SKIPPED: 'Onboarding: Inbox setup skipped',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ export const INBOX_TYPES = {
|
|||||||
TIKTOK: 'Channel::Tiktok',
|
TIKTOK: 'Channel::Tiktok',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Short channel-type slugs used to identify a channel without leaning on its
|
||||||
|
// Channel:: class name — e.g. onboarding channel cards and OAuth provider maps.
|
||||||
|
export const CHANNEL_TYPES = {
|
||||||
|
WEBSITE: 'website',
|
||||||
|
WHATSAPP: 'whatsapp',
|
||||||
|
FACEBOOK: 'facebook',
|
||||||
|
INSTAGRAM: 'instagram',
|
||||||
|
TIKTOK: 'tiktok',
|
||||||
|
TELEGRAM: 'telegram',
|
||||||
|
LINE: 'line',
|
||||||
|
GMAIL: 'gmail',
|
||||||
|
OUTLOOK: 'outlook',
|
||||||
|
SMS: 'sms',
|
||||||
|
API: 'api',
|
||||||
|
VOICE: 'voice',
|
||||||
|
EMAIL: 'email',
|
||||||
|
};
|
||||||
|
|
||||||
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
// Add providers here as they gain voice capability (e.g., WhatsApp Cloud, Twilio WhatsApp)
|
||||||
export const VOICE_CALL_PROVIDERS = {
|
export const VOICE_CALL_PROVIDERS = {
|
||||||
TWILIO: 'twilio',
|
TWILIO: 'twilio',
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats seconds into a human-readable time string
|
||||||
|
* @param {number} seconds - The time in seconds (can be negative for overdue)
|
||||||
|
* @returns {string} Formatted time string like "2h 30m" or "1d 4h"
|
||||||
|
*/
|
||||||
|
const formatSLATime = seconds => {
|
||||||
|
const absSeconds = Math.abs(seconds);
|
||||||
|
|
||||||
|
const units = {
|
||||||
|
y: 31536000,
|
||||||
|
mo: 2592000,
|
||||||
|
d: 86400,
|
||||||
|
h: 3600,
|
||||||
|
m: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (absSeconds < 60) {
|
||||||
|
return '1m';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
let remaining = absSeconds;
|
||||||
|
|
||||||
|
Object.entries(units).forEach(([unit, value]) => {
|
||||||
|
if (parts.length >= 2) return;
|
||||||
|
const count = Math.floor(remaining / value);
|
||||||
|
if (count > 0) {
|
||||||
|
parts.push(`${count}${unit}`);
|
||||||
|
remaining -= count * value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return parts.join(' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toUnixTimestamp = value => {
|
||||||
|
if (!value || typeof value === 'number') return value;
|
||||||
|
|
||||||
|
const numericValue = Number(value);
|
||||||
|
if (!Number.isNaN(numericValue)) return numericValue;
|
||||||
|
|
||||||
|
const parsedTimestamp = Date.parse(value);
|
||||||
|
return Number.isNaN(parsedTimestamp)
|
||||||
|
? value
|
||||||
|
: Math.floor(parsedTimestamp / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates SLA status using backend-computed due times
|
||||||
|
* @param {Object} params - Parameters object
|
||||||
|
* @param {Object} params.appliedSla - The applied SLA with due_at timestamps
|
||||||
|
* @param {Object} params.chat - The conversation object
|
||||||
|
* @param {Array} params.slaEvents - Recorded SLA miss events for this conversation
|
||||||
|
* @returns {Object} SLA status with type, threshold, icon, and isSlaMissed
|
||||||
|
*/
|
||||||
|
export const evaluateSLAStatus = ({ appliedSla, chat, slaEvents = [] }) => {
|
||||||
|
const emptyStatus = { type: '', threshold: '', icon: '', isSlaMissed: false };
|
||||||
|
|
||||||
|
if (!appliedSla || !chat) {
|
||||||
|
return emptyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sla = useCamelCase(appliedSla);
|
||||||
|
const conversation = useCamelCase(chat);
|
||||||
|
const events = useCamelCase(slaEvents || []);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
const slaStatuses = [];
|
||||||
|
|
||||||
|
const dueAtByType = {
|
||||||
|
FRT: sla.slaFrtDueAt,
|
||||||
|
RT: sla.slaRtDueAt,
|
||||||
|
};
|
||||||
|
const slaTypes = ['FRT', 'NRT', 'RT'];
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
const type = event.eventType?.toUpperCase();
|
||||||
|
if (!slaTypes.includes(type)) return;
|
||||||
|
|
||||||
|
const missedAt =
|
||||||
|
type === 'NRT' ? event.createdAt : dueAtByType[type] || event.createdAt;
|
||||||
|
if (!missedAt) return;
|
||||||
|
|
||||||
|
slaStatuses.push({
|
||||||
|
type,
|
||||||
|
threshold: missedAt - currentTime,
|
||||||
|
icon: 'flame',
|
||||||
|
isSlaMissed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstReplyCreatedAt = toUnixTimestamp(conversation.firstReplyCreatedAt);
|
||||||
|
const shouldCheckFirstResponse =
|
||||||
|
!firstReplyCreatedAt || firstReplyCreatedAt > sla.slaFrtDueAt;
|
||||||
|
|
||||||
|
// Check FRT - until first reply is made on time
|
||||||
|
if (sla.slaFrtDueAt && shouldCheckFirstResponse) {
|
||||||
|
const threshold = sla.slaFrtDueAt - currentTime;
|
||||||
|
slaStatuses.push({
|
||||||
|
type: 'FRT',
|
||||||
|
threshold,
|
||||||
|
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||||
|
isSlaMissed: threshold <= 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check NRT - only if first reply made and waiting for response
|
||||||
|
if (sla.slaNrtDueAt && firstReplyCreatedAt && conversation.waitingSince) {
|
||||||
|
const threshold = sla.slaNrtDueAt - currentTime;
|
||||||
|
slaStatuses.push({
|
||||||
|
type: 'NRT',
|
||||||
|
threshold,
|
||||||
|
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||||
|
isSlaMissed: threshold <= 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check RT - only if conversation is unresolved
|
||||||
|
if (sla.slaRtDueAt && conversation.status !== 'resolved') {
|
||||||
|
const threshold = sla.slaRtDueAt - currentTime;
|
||||||
|
slaStatuses.push({
|
||||||
|
type: 'RT',
|
||||||
|
threshold,
|
||||||
|
icon: threshold <= 0 ? 'flame' : 'alarm',
|
||||||
|
isSlaMissed: threshold <= 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slaStatuses.length === 0) {
|
||||||
|
return emptyStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show existing breaches before upcoming deadlines, then pick the closest timer.
|
||||||
|
slaStatuses.sort((a, b) => {
|
||||||
|
if (a.isSlaMissed !== b.isSlaMissed) {
|
||||||
|
return a.isSlaMissed ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.abs(a.threshold) - Math.abs(b.threshold);
|
||||||
|
});
|
||||||
|
const mostUrgent = slaStatuses[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: mostUrgent.type,
|
||||||
|
threshold: formatSLATime(mostUrgent.threshold),
|
||||||
|
icon: mostUrgent.icon,
|
||||||
|
isSlaMissed: mostUrgent.isSlaMissed,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { EditorState, EditorView } from '@chatwoot/prosemirror-schema';
|
import {
|
||||||
|
EditorState,
|
||||||
|
EditorView,
|
||||||
|
buildMessageSchema,
|
||||||
|
MessageMarkdownTransformer,
|
||||||
|
} from '@chatwoot/prosemirror-schema';
|
||||||
import { FORMATTING } from 'dashboard/constants/editor';
|
import { FORMATTING } from 'dashboard/constants/editor';
|
||||||
import { Schema } from 'prosemirror-model';
|
import { Schema } from 'prosemirror-model';
|
||||||
import {
|
import {
|
||||||
@@ -978,13 +983,77 @@ describe('stripUnsupportedFormatting', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips links but keeps text', () => {
|
it('keeps link text and URL when schema does not support links', () => {
|
||||||
expect(
|
expect(
|
||||||
stripUnsupportedFormatting(
|
stripUnsupportedFormatting(
|
||||||
'Check [this link](https://example.com)',
|
'Check [this link](https://example.com)',
|
||||||
emptySchema
|
emptySchema
|
||||||
)
|
)
|
||||||
).toBe('Check this link');
|
).toBe('Check this link: https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the hidden link title when preserving the URL', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting(
|
||||||
|
'Check [docs](https://example.com "Docs")',
|
||||||
|
emptySchema
|
||||||
|
)
|
||||||
|
).toBe('Check docs: https://example.com');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting(
|
||||||
|
'Check [docs](<https://example.com> "Docs")',
|
||||||
|
emptySchema
|
||||||
|
)
|
||||||
|
).toBe('Check docs: https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Output is re-parsed before sending, so assert the final text
|
||||||
|
// (strip + re-parse); the re-parse turns serializer escapes into literals.
|
||||||
|
describe('links round-trip through re-parse without crashing', () => {
|
||||||
|
const smsSchema = buildMessageSchema([], []); // no marks, no nodes
|
||||||
|
const sendAs = md =>
|
||||||
|
new MessageMarkdownTransformer(smsSchema).parse(
|
||||||
|
stripUnsupportedFormatting(md, smsSchema)
|
||||||
|
).textContent;
|
||||||
|
|
||||||
|
it('keeps escaped parens/underscores anywhere in the URL', () => {
|
||||||
|
expect(
|
||||||
|
sendAs('See [wiki](https://en.wikipedia.org/wiki/Foo\\_\\(bar\\))')
|
||||||
|
).toBe('See wiki: https://en.wikipedia.org/wiki/Foo_(bar)');
|
||||||
|
expect(sendAs('See [wiki](https://host/a\\_\\(b\\)c)')).toBe(
|
||||||
|
'See wiki: https://host/a_(b)c'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the label when it equals the URL even when escaped', () => {
|
||||||
|
expect(
|
||||||
|
sendAs(
|
||||||
|
'[www.example.com/Foo\\_\\(bar\\)](www.example.com/Foo\\_\\(bar\\))'
|
||||||
|
)
|
||||||
|
).toBe('www.example.com/Foo_(bar)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reintroduce emphasis from an escaped label', () => {
|
||||||
|
expect(sendAs('[Use \\_id\\_](https://example.com)')).toBe(
|
||||||
|
'Use _id_: https://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flattens a label containing an escaped closing bracket', () => {
|
||||||
|
expect(sendAs('[FAQ \\[v2\\]](https://example.com)')).toBe(
|
||||||
|
'FAQ [v2]: https://example.com'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves bare URLs untouched so channels can auto-link them', () => {
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('Visit www.example.com now', emptySchema)
|
||||||
|
).toBe('Visit www.example.com now');
|
||||||
|
expect(
|
||||||
|
stripUnsupportedFormatting('Visit <https://example.com>', emptySchema)
|
||||||
|
).toBe('Visit https://example.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('converts autolinks to plain URLs when schema does not support links', () => {
|
it('converts autolinks to plain URLs when schema does not support links', () => {
|
||||||
@@ -1049,7 +1118,7 @@ describe('stripUnsupportedFormatting', () => {
|
|||||||
it('handles complex content with multiple formatting types', () => {
|
it('handles complex content with multiple formatting types', () => {
|
||||||
const content =
|
const content =
|
||||||
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
'**Bold** and *italic* with `code` and [link](url)\n- list item';
|
||||||
const expected = 'Bold and italic with code and link\nlist item';
|
const expected = 'Bold and italic with code and link: url\nlist item';
|
||||||
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
expect(stripUnsupportedFormatting(content, emptySchema)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { evaluateSLAStatus } from '../slaHelper';
|
||||||
|
|
||||||
|
describe('#SLA Helpers', () => {
|
||||||
|
const currentTimestamp = 1700000000; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(currentTimestamp * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('evaluateSLAStatus', () => {
|
||||||
|
describe('when inputs are invalid', () => {
|
||||||
|
it('returns empty status when appliedSla is null', () => {
|
||||||
|
const result = evaluateSLAStatus({ appliedSla: null, chat: {} });
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: '',
|
||||||
|
threshold: '',
|
||||||
|
icon: '',
|
||||||
|
isSlaMissed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty status when chat is null', () => {
|
||||||
|
const result = evaluateSLAStatus({ appliedSla: {}, chat: null });
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: '',
|
||||||
|
threshold: '',
|
||||||
|
icon: '',
|
||||||
|
isSlaMissed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FRT (First Response Time)', () => {
|
||||||
|
it('returns FRT status when first reply not made and within threshold', () => {
|
||||||
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 }; // 1 hour from now
|
||||||
|
const chat = { first_reply_created_at: null, status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('1h');
|
||||||
|
expect(result.icon).toBe('alarm');
|
||||||
|
expect(result.isSlaMissed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns missed FRT status when threshold is exceeded', () => {
|
||||||
|
const appliedSla = { sla_frt_due_at: currentTimestamp - 1800 }; // 30 min ago
|
||||||
|
const chat = { first_reply_created_at: null, status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('30m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return FRT when first reply was made before due time', () => {
|
||||||
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 3600 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 1000,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).not.toBe('FRT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return FRT when first reply was made at due time', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp - 600,
|
||||||
|
sla_rt_due_at: currentTimestamp + 1800,
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 600,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns missed FRT when first reply was made after due time', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp - 600,
|
||||||
|
sla_rt_due_at: currentTimestamp + 1800,
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 300,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('10m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns missed FRT when live first reply timestamp is an ISO string after due time', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp - 600,
|
||||||
|
sla_rt_due_at: currentTimestamp + 1800,
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: new Date(
|
||||||
|
(currentTimestamp - 300) * 1000
|
||||||
|
).toISOString(),
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('10m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the due time for a missed FRT event created after the deadline', () => {
|
||||||
|
const appliedSla = { sla_frt_due_at: currentTimestamp - 3600 };
|
||||||
|
const chat = { first_reply_created_at: null, status: 'open' };
|
||||||
|
const slaEvents = [
|
||||||
|
{ event_type: 'frt', created_at: currentTimestamp - 1800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('1h');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NRT (Next Response Time)', () => {
|
||||||
|
it('returns NRT status when waiting for response and within threshold', () => {
|
||||||
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 }; // 30 min from now
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: currentTimestamp - 600,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('NRT');
|
||||||
|
expect(result.threshold).toBe('30m');
|
||||||
|
expect(result.icon).toBe('alarm');
|
||||||
|
expect(result.isSlaMissed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns missed NRT status when threshold is exceeded', () => {
|
||||||
|
const appliedSla = { sla_nrt_due_at: currentTimestamp - 900 }; // 15 min ago
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: currentTimestamp - 2700,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('NRT');
|
||||||
|
expect(result.threshold).toBe('15m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return NRT when not waiting for response', () => {
|
||||||
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: null,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).not.toBe('NRT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns missed NRT when a recorded NRT miss exists after response', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 1800 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: null,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
const slaEvents = [
|
||||||
|
{ event_type: 'nrt', created_at: currentTimestamp - 900 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||||
|
|
||||||
|
expect(result.type).toBe('NRT');
|
||||||
|
expect(result.threshold).toBe('15m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the recorded event time for a missed NRT with a new active NRT timer', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_nrt_due_at: currentTimestamp + 300,
|
||||||
|
sla_rt_due_at: currentTimestamp + 1800,
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: currentTimestamp - 60,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
const slaEvents = [
|
||||||
|
{ event_type: 'nrt', created_at: currentTimestamp - 1800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||||
|
|
||||||
|
expect(result.type).toBe('NRT');
|
||||||
|
expect(result.threshold).toBe('30m');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return NRT when first reply not made', () => {
|
||||||
|
const appliedSla = { sla_nrt_due_at: currentTimestamp + 1800 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: null,
|
||||||
|
waiting_since: currentTimestamp - 600,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).not.toBe('NRT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RT (Resolution Time)', () => {
|
||||||
|
it('returns RT status when conversation is open and within threshold', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 }; // 2 hours from now
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 3600,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
expect(result.threshold).toBe('2h');
|
||||||
|
expect(result.icon).toBe('alarm');
|
||||||
|
expect(result.isSlaMissed).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['pending', 'snoozed'])(
|
||||||
|
'returns RT status when conversation is %s and within threshold',
|
||||||
|
status => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 3600,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
expect(result.threshold).toBe('2h');
|
||||||
|
expect(result.icon).toBe('alarm');
|
||||||
|
expect(result.isSlaMissed).toBe(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it('returns missed RT status when threshold is exceeded', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 }; // 1 hour ago
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
expect(result.threshold).toBe('1h');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the due time for a missed RT event created after the deadline', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp - 3600 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
const slaEvents = [
|
||||||
|
{ event_type: 'rt', created_at: currentTimestamp - 1800 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat, slaEvents });
|
||||||
|
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
expect(result.threshold).toBe('1h');
|
||||||
|
expect(result.icon).toBe('flame');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return RT when conversation is resolved', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 7200 };
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 3600,
|
||||||
|
status: 'resolved',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('priority selection', () => {
|
||||||
|
it('returns most urgent SLA when multiple are active', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp + 7200, // 2h - less urgent
|
||||||
|
sla_nrt_due_at: currentTimestamp + 1800, // 30m - most urgent
|
||||||
|
sla_rt_due_at: currentTimestamp + 3600, // 1h
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: null,
|
||||||
|
waiting_since: currentTimestamp - 600,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
// FRT is selected because first_reply_created_at is null
|
||||||
|
// NRT is not checked when first_reply_created_at is null
|
||||||
|
expect(result.type).toBe('RT');
|
||||||
|
expect(result.threshold).toBe('1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns most urgent missed SLA over upcoming SLA', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_nrt_due_at: currentTimestamp - 300, // 5m overdue - most urgent by absolute value
|
||||||
|
sla_rt_due_at: currentTimestamp + 3600, // 1h remaining
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 7200,
|
||||||
|
waiting_since: currentTimestamp - 2100,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('NRT');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an existing missed SLA over a closer upcoming SLA', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp - 7200, // 2h overdue
|
||||||
|
sla_rt_due_at: currentTimestamp + 300, // 5m remaining
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: null,
|
||||||
|
status: 'open',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.type).toBe('FRT');
|
||||||
|
expect(result.threshold).toBe('2h');
|
||||||
|
expect(result.isSlaMissed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('time formatting', () => {
|
||||||
|
it('formats time in days and hours', () => {
|
||||||
|
const appliedSla = { sla_rt_due_at: currentTimestamp + 90000 }; // 25 hours
|
||||||
|
const chat = { status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.threshold).toBe('1d 1h');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats time less than a minute as 1m', () => {
|
||||||
|
const appliedSla = { sla_frt_due_at: currentTimestamp + 30 }; // 30 seconds
|
||||||
|
const chat = { first_reply_created_at: null, status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.threshold).toBe('1m');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats months correctly', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_rt_due_at: currentTimestamp + 2592000 + 86400,
|
||||||
|
}; // 1 month + 1 day
|
||||||
|
const chat = { status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result.threshold).toBe('1mo 1d');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('empty status scenarios', () => {
|
||||||
|
it('returns empty when no SLA thresholds are set', () => {
|
||||||
|
const appliedSla = {};
|
||||||
|
const chat = { status: 'open' };
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: '',
|
||||||
|
threshold: '',
|
||||||
|
icon: '',
|
||||||
|
isSlaMissed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when all conditions are met', () => {
|
||||||
|
const appliedSla = {
|
||||||
|
sla_frt_due_at: currentTimestamp + 3600,
|
||||||
|
sla_nrt_due_at: currentTimestamp + 1800,
|
||||||
|
sla_rt_due_at: currentTimestamp + 7200,
|
||||||
|
};
|
||||||
|
const chat = {
|
||||||
|
first_reply_created_at: currentTimestamp - 3600, // FRT already hit
|
||||||
|
waiting_since: null, // Not waiting, so NRT not applicable
|
||||||
|
status: 'resolved', // RT not applicable
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = evaluateSLAStatus({ appliedSla, chat });
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
type: '',
|
||||||
|
threshold: '',
|
||||||
|
icon: '',
|
||||||
|
isSlaMissed: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
|
import {
|
||||||
|
CONTENT_TYPES,
|
||||||
|
VOICE_CALL_STATUS,
|
||||||
|
} from 'dashboard/components-next/message/constants';
|
||||||
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
import { MESSAGE_TYPE } from 'shared/constants/messages';
|
||||||
import { useCallsStore } from 'dashboard/stores/calls';
|
import { useCallsStore } from 'dashboard/stores/calls';
|
||||||
import types from 'dashboard/store/mutation-types';
|
import types from 'dashboard/store/mutation-types';
|
||||||
@@ -7,6 +10,7 @@ export const TERMINAL_STATUSES = [
|
|||||||
'completed',
|
'completed',
|
||||||
'busy',
|
'busy',
|
||||||
'failed',
|
'failed',
|
||||||
|
'rejected',
|
||||||
'no-answer',
|
'no-answer',
|
||||||
'canceled',
|
'canceled',
|
||||||
'missed',
|
'missed',
|
||||||
@@ -100,6 +104,7 @@ export function handleVoiceCallCreated(
|
|||||||
callSid,
|
callSid,
|
||||||
callId,
|
callId,
|
||||||
provider,
|
provider,
|
||||||
|
status,
|
||||||
callDirection,
|
callDirection,
|
||||||
conversationId,
|
conversationId,
|
||||||
inboxId,
|
inboxId,
|
||||||
@@ -107,6 +112,11 @@ export function handleVoiceCallCreated(
|
|||||||
senderId,
|
senderId,
|
||||||
} = extractCallData(message);
|
} = extractCallData(message);
|
||||||
|
|
||||||
|
// A voice_call message can be created already terminal when the caller hangs
|
||||||
|
// up before connect. Only ring while the call is actually ringing; mirrors the
|
||||||
|
// guard in seedCallsFromHydratedMessages.
|
||||||
|
if (status !== VOICE_CALL_STATUS.RINGING) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!shouldShowCall({
|
!shouldShowCall({
|
||||||
callDirection,
|
callDirection,
|
||||||
|
|||||||
@@ -494,10 +494,6 @@
|
|||||||
"PLACEHOLDER": "Enter assistant name",
|
"PLACEHOLDER": "Enter assistant name",
|
||||||
"ERROR": "The name is required"
|
"ERROR": "The name is required"
|
||||||
},
|
},
|
||||||
"TEMPERATURE": {
|
|
||||||
"LABEL": "Response Temperature",
|
|
||||||
"DESCRIPTION": "Adjust how creative or restrictive the assistant's responses should be. Lower values produce more focused and deterministic responses, while higher values allow for more creative and varied outputs."
|
|
||||||
},
|
|
||||||
"DESCRIPTION": {
|
"DESCRIPTION": {
|
||||||
"LABEL": "Description",
|
"LABEL": "Description",
|
||||||
"PLACEHOLDER": "Enter assistant description",
|
"PLACEHOLDER": "Enter assistant description",
|
||||||
|
|||||||
@@ -30,5 +30,59 @@
|
|||||||
"VALIDATION_ERROR": "Please fill in all required fields",
|
"VALIDATION_ERROR": "Please fill in all required fields",
|
||||||
"SUCCESS": "Details saved successfully",
|
"SUCCESS": "Details saved successfully",
|
||||||
"ERROR": "Could not save details. Please try again."
|
"ERROR": "Could not save details. Please try again."
|
||||||
|
},
|
||||||
|
"ONBOARDING_INBOX_SETUP": {
|
||||||
|
"GREETING": "Let's set up a few things",
|
||||||
|
"SUBTITLE": "This will give you head-start in your workspace",
|
||||||
|
"CONTINUE": "Continue",
|
||||||
|
"SKIP": "Skip",
|
||||||
|
"ERROR": "Something went wrong. Please try again.",
|
||||||
|
"WHATSAPP_CONNECTED": "WhatsApp connected successfully",
|
||||||
|
"FACEBOOK_CONNECTED": "Facebook connected successfully",
|
||||||
|
"CREATED_FOR_YOU": {
|
||||||
|
"TITLE": "We've created the following for you",
|
||||||
|
"LIVE_CHAT": "Live Chat widget",
|
||||||
|
"LIVE_CHAT_DESCRIPTION": "Instant messenger for your website",
|
||||||
|
"LIVE_CHAT_STATUS": "Almost done…",
|
||||||
|
"LIVE_CHAT_READY": "Ready",
|
||||||
|
"HELP_CENTER": "Help Center",
|
||||||
|
"HELP_CENTER_DESCRIPTION": "Your digital encyclopedia",
|
||||||
|
"HELP_CENTER_GENERATING": "Creating your help center…",
|
||||||
|
"HELP_CENTER_ANALYZING_WEBSITE": "Analyzing your website…",
|
||||||
|
"HELP_CENTER_SETTING_UP_CATEGORIES": "Setting up categories…",
|
||||||
|
"HELP_CENTER_CURATING_ARTICLES": "Curating articles…",
|
||||||
|
"HELP_CENTER_ARTICLES": "Created {count} article | Created {count} articles",
|
||||||
|
"HELP_CENTER_CATEGORIES": "{count} category | {count} categories",
|
||||||
|
"HELP_CENTER_SUMMARY": "Created {count} article across {categories} | Created {count} articles across {categories}"
|
||||||
|
},
|
||||||
|
"CHANNELS": {
|
||||||
|
"TITLE": "Connect all your conversation channels",
|
||||||
|
"HEADER": "We found a few channels you can connect",
|
||||||
|
"CONNECT": "Connect",
|
||||||
|
"CONNECTED": "Connected",
|
||||||
|
"MORE_CHANNELS_NOTE": "Set up {email} and {voice} channels later inside the app",
|
||||||
|
"MORE_CHANNELS_EMAIL": "Email",
|
||||||
|
"MORE_CHANNELS_VOICE": "Voice",
|
||||||
|
"VIEW_ALL": "View all",
|
||||||
|
"GMAIL": "Gmail",
|
||||||
|
"OUTLOOK": "Outlook",
|
||||||
|
"OTHER_EMAIL": "Other Email Providers"
|
||||||
|
},
|
||||||
|
"CHANNELS_DIALOG": {
|
||||||
|
"TITLE": "Connect all your channels instantly",
|
||||||
|
"SUBTITLE": "Manage all of them from one dashboard. You can also set up and edit inboxes later inside the app.",
|
||||||
|
"NOTE": "SMS, API, Voice, and other email providers can be set up later from your dashboard.",
|
||||||
|
"CONNECT_TITLE": "Connect your {name} account",
|
||||||
|
"CONNECT_SUBTITLE": "Fill out these quick details",
|
||||||
|
"CONNECT": "Connect",
|
||||||
|
"BACK": "Back",
|
||||||
|
"SETUP_LATER": "Setup later in app",
|
||||||
|
"FACEBOOK_SUBTITLE": "Authorize access and choose a Page to connect.",
|
||||||
|
"FACEBOOK_LAUNCH": "Continue with Facebook",
|
||||||
|
"FACEBOOK_SELECT_PAGE": "Select a Page to connect",
|
||||||
|
"FACEBOOK_LOADING": "Loading your Facebook Pages…",
|
||||||
|
"FACEBOOK_NO_PAGES": "No connectable Pages found. All your Pages are already connected.",
|
||||||
|
"FACEBOOK_ERROR": "Couldn't connect to Facebook. Please try again."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,26 @@
|
|||||||
"CLEAR_FILTER": "Clear filter",
|
"CLEAR_FILTER": "Clear filter",
|
||||||
"EMPTY_LIST": "No results found"
|
"EMPTY_LIST": "No results found"
|
||||||
},
|
},
|
||||||
|
"DRILLDOWN": {
|
||||||
|
"TITLE": "{metric} details",
|
||||||
|
"RESULT_COUNT_CONVERSATION": "{count} conversation | {count} conversations",
|
||||||
|
"RESULT_COUNT_MESSAGE": "{count} message | {count} messages",
|
||||||
|
"EMPTY": "No records found for this bar.",
|
||||||
|
"ERROR": "Could not load records. Please try again.",
|
||||||
|
"ADMIN_ONLY": "Only administrators can drill down into report records.",
|
||||||
|
"LOAD_MORE": "Load more",
|
||||||
|
"CLOSE": "Close details",
|
||||||
|
"PREVIOUS_BUCKET": "Previous bar",
|
||||||
|
"NEXT_BUCKET": "Next bar",
|
||||||
|
"UNKNOWN_CONTACT": "Unknown contact",
|
||||||
|
"UNKNOWN_INBOX": "Unknown inbox",
|
||||||
|
"UNASSIGNED_AGENT": "Unassigned",
|
||||||
|
"NO_MESSAGE_CONTENT": "No message content",
|
||||||
|
"MESSAGE_CREATED_AT": "Message created at {time}",
|
||||||
|
"EVENT_OCCURRED_AT": "Event occurred at {time}",
|
||||||
|
"INCOMING_MESSAGE": "Incoming message",
|
||||||
|
"OUTGOING_MESSAGE": "Outgoing message"
|
||||||
|
},
|
||||||
"PAGINATION": {
|
"PAGINATION": {
|
||||||
"RESULTS": "Showing {start} to {end} of {total} results",
|
"RESULTS": "Showing {start} to {end} of {total} results",
|
||||||
"PER_PAGE_TEMPLATE": "{size} / page"
|
"PER_PAGE_TEMPLATE": "{size} / page"
|
||||||
|
|||||||
@@ -440,7 +440,9 @@
|
|||||||
"DESCRIPTION": "Enable or disable AI-powered features.",
|
"DESCRIPTION": "Enable or disable AI-powered features.",
|
||||||
"AUDIO_TRANSCRIPTION": {
|
"AUDIO_TRANSCRIPTION": {
|
||||||
"TITLE": "Audio Transcription",
|
"TITLE": "Audio Transcription",
|
||||||
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts."
|
"DESCRIPTION": "Automatically convert voice messages and call recordings into searchable text transcripts.",
|
||||||
|
"MODEL_TITLE": "Audio Transcription Model",
|
||||||
|
"MODEL_DESCRIPTION": "Select the AI model to use for converting audio messages into text transcripts"
|
||||||
},
|
},
|
||||||
"HELP_CENTER_SEARCH": {
|
"HELP_CENTER_SEARCH": {
|
||||||
"TITLE": "Help Center Search Indexing",
|
"TITLE": "Help Center Search Indexing",
|
||||||
@@ -798,10 +800,14 @@
|
|||||||
},
|
},
|
||||||
"FAIR_DISTRIBUTION": {
|
"FAIR_DISTRIBUTION": {
|
||||||
"LABEL": "Fair distribution policy",
|
"LABEL": "Fair distribution policy",
|
||||||
"DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.",
|
"DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.",
|
||||||
"INPUT_MAX": "Assign max",
|
"INPUT_MAX": "Assign max",
|
||||||
"DURATION": "Conversations per agent in every"
|
"DURATION": "Conversations per agent in every"
|
||||||
},
|
},
|
||||||
|
"EXCLUDE_OLDER_THAN": {
|
||||||
|
"LABEL": "Skip stale conversations",
|
||||||
|
"DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable."
|
||||||
|
},
|
||||||
"INBOXES": {
|
"INBOXES": {
|
||||||
"LABEL": "Added inboxes",
|
"LABEL": "Added inboxes",
|
||||||
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
|
"DESCRIPTION": "Add inboxes for which this policy will be applicable.",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
|||||||
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
|
||||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_EVENTS,
|
ACCOUNT_EVENTS,
|
||||||
@@ -119,16 +120,20 @@ export default {
|
|||||||
handleClose(e) {
|
handleClose(e) {
|
||||||
this.$emit('close', e);
|
this.$emit('close', e);
|
||||||
},
|
},
|
||||||
handleTranslate() {
|
async handleTranslate() {
|
||||||
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
const { locale: accountLocale } = this.getAccount(this.currentAccountId);
|
||||||
const agentLocale = this.getUISettings?.locale;
|
const agentLocale = this.getUISettings?.locale;
|
||||||
const targetLanguage = agentLocale || accountLocale || 'en';
|
const targetLanguage = agentLocale || accountLocale || 'en';
|
||||||
this.$store.dispatch('translateMessage', {
|
try {
|
||||||
conversationId: this.conversationId,
|
await this.$store.dispatch('translateMessage', {
|
||||||
messageId: this.messageId,
|
conversationId: this.conversationId,
|
||||||
targetLanguage,
|
messageId: this.messageId,
|
||||||
});
|
targetLanguage,
|
||||||
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
});
|
||||||
|
useTrack(CONVERSATION_EVENTS.TRANSLATE_A_MESSAGE);
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(parseAPIErrorResponse(error));
|
||||||
|
}
|
||||||
this.handleClose();
|
this.handleClose();
|
||||||
},
|
},
|
||||||
handleReplyTo() {
|
handleReplyTo() {
|
||||||
|
|||||||
@@ -252,6 +252,7 @@ export default {
|
|||||||
<MultiselectDropdown
|
<MultiselectDropdown
|
||||||
:options="teamsList"
|
:options="teamsList"
|
||||||
:selected-item="assignedTeam"
|
:selected-item="assignedTeam"
|
||||||
|
show-emoji-icon
|
||||||
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
|
:multiselector-title="$t('AGENT_MGMT.MULTI_SELECTOR.TITLE.TEAM')"
|
||||||
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
|
:multiselector-placeholder="$t('AGENT_MGMT.MULTI_SELECTOR.PLACEHOLDER')"
|
||||||
:no-search-result="
|
:no-search-result="
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import AppContainer from './Dashboard.vue';
|
|||||||
import Suspended from './suspended/Index.vue';
|
import Suspended from './suspended/Index.vue';
|
||||||
import NoAccounts from './noAccounts/Index.vue';
|
import NoAccounts from './noAccounts/Index.vue';
|
||||||
import OnboardingAccountDetails from './onboarding/Index.vue';
|
import OnboardingAccountDetails from './onboarding/Index.vue';
|
||||||
|
import OnboardingInboxSetup from './onboarding/InboxSetup.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -40,6 +41,14 @@ export default {
|
|||||||
},
|
},
|
||||||
component: OnboardingAccountDetails,
|
component: OnboardingAccountDetails,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: frontendURL('accounts/:accountId/onboarding/inbox-setup'),
|
||||||
|
name: 'onboarding_inbox_setup',
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator', 'agent', 'custom_role'],
|
||||||
|
},
|
||||||
|
component: OnboardingInboxSetup,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: frontendURL('accounts/:accountId/suspended'),
|
path: frontendURL('accounts/:accountId/suspended'),
|
||||||
name: 'account_suspended',
|
name: 'account_suspended',
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||||
|
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||||
|
import InboxChannelsDialog from './inbox-setup/InboxChannelsDialog.vue';
|
||||||
|
import InboxChannelsFooter from './inbox-setup/InboxChannelsFooter.vue';
|
||||||
|
import ChannelRow from './inbox-setup/ChannelRow.vue';
|
||||||
|
import WebWidgetCreationStatus from './inbox-setup/WebWidgetCreationStatus.vue';
|
||||||
|
import HelpCenterCreationStatus from './inbox-setup/HelpCenterCreationStatus.vue';
|
||||||
|
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { useChannelConnect } from './inbox-setup/useChannelConnect';
|
||||||
|
import { useDetectedChannels } from './inbox-setup/useDetectedChannels';
|
||||||
|
import { DIALOG_CHANNELS } from './inbox-setup/constants';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||||
|
const { isEnterprise } = useConfig();
|
||||||
|
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||||
|
|
||||||
|
const helpCenterGenerationId = computed(
|
||||||
|
() => currentAccount.value?.custom_attributes?.help_center_generation_id
|
||||||
|
);
|
||||||
|
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
|
||||||
|
const {
|
||||||
|
displayedChannels,
|
||||||
|
remainingChannels,
|
||||||
|
connectedInbox,
|
||||||
|
hasDetectedChannels,
|
||||||
|
} = useDetectedChannels();
|
||||||
|
|
||||||
|
const channelsDialogRef = ref(null);
|
||||||
|
|
||||||
|
// The initial inboxes fetch happens in WebWidgetCreationStatus, which polls
|
||||||
|
// `inboxes/get` from its own mount — no need to dispatch it here too.
|
||||||
|
onMounted(() => {
|
||||||
|
useTrack(ONBOARDING_EVENTS.INBOX_SETUP_VISITED);
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeOnboarding = async event => {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
// Declare the step we're completing so the controller only clears it when
|
||||||
|
// the stored step still matches (idempotent). setUser then refreshes the
|
||||||
|
// auth store so the router guard sees the cleared step and lets us in.
|
||||||
|
await finishOnboarding({ onboarding_step: 'inbox_setup' });
|
||||||
|
useTrack(event);
|
||||||
|
await store.dispatch('setUser');
|
||||||
|
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||||
|
} catch {
|
||||||
|
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContinue = () =>
|
||||||
|
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_COMPLETED);
|
||||||
|
const handleSkip = () =>
|
||||||
|
completeOnboarding(ONBOARDING_EVENTS.INBOX_SETUP_SKIPPED);
|
||||||
|
const openChannelsDialog = () => channelsDialogRef.value?.open();
|
||||||
|
const refetchInboxes = () => store.dispatch('inboxes/get');
|
||||||
|
|
||||||
|
// WhatsApp connects via Meta's embedded-signup popup; the DIALOG_CHANNELS open
|
||||||
|
// the channels dialog preselected to their in-dialog step; the rest go through
|
||||||
|
// the redirect OAuth flow (Gmail/Outlook keyed by email provider, Instagram by
|
||||||
|
// channel type).
|
||||||
|
const connectChannel = channel => {
|
||||||
|
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||||
|
connectWhatsapp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (DIALOG_CHANNELS.includes(channel.type)) {
|
||||||
|
channelsDialogRef.value?.open(channel.type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectViaOAuth(channel.inbox?.provider || channel.type);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<OnboardingLayout
|
||||||
|
:greeting="t('ONBOARDING_INBOX_SETUP.GREETING')"
|
||||||
|
:subtitle="t('ONBOARDING_INBOX_SETUP.SUBTITLE')"
|
||||||
|
:continue-label="t('ONBOARDING_INBOX_SETUP.CONTINUE')"
|
||||||
|
:skip-label="t('ONBOARDING_INBOX_SETUP.SKIP')"
|
||||||
|
:is-loading="isSubmitting"
|
||||||
|
@continue="handleContinue"
|
||||||
|
@skip="handleSkip"
|
||||||
|
>
|
||||||
|
<template #greeting-icon>
|
||||||
|
<Icon icon="i-lucide-wrench" class="size-4 text-n-slate-7" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<OnboardingSection
|
||||||
|
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.TITLE')"
|
||||||
|
icon="i-lucide-sparkles"
|
||||||
|
>
|
||||||
|
<div class="divide-y divide-n-weak">
|
||||||
|
<WebWidgetCreationStatus />
|
||||||
|
<HelpCenterCreationStatus
|
||||||
|
v-if="isEnterprise && helpCenterGenerationId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</OnboardingSection>
|
||||||
|
|
||||||
|
<OnboardingSection
|
||||||
|
:title="t('ONBOARDING_INBOX_SETUP.CHANNELS.TITLE')"
|
||||||
|
icon="i-lucide-inbox"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="hasDetectedChannels"
|
||||||
|
class="flex items-center gap-2 p-3 border-b border-dashed border-n-strong"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon="i-lucide-lightbulb"
|
||||||
|
class="size-4 text-n-slate-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="flex-1 min-w-0 text-body-main text-n-slate-11">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.HEADER') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChannelRow
|
||||||
|
v-for="channel in displayedChannels"
|
||||||
|
:key="channel.type"
|
||||||
|
:channel="channel"
|
||||||
|
:connected-inbox="connectedInbox(channel)"
|
||||||
|
@connect="connectChannel"
|
||||||
|
/>
|
||||||
|
<InboxChannelsFooter
|
||||||
|
:remaining-channels="remainingChannels"
|
||||||
|
@view-all="openChannelsDialog"
|
||||||
|
/>
|
||||||
|
</OnboardingSection>
|
||||||
|
</OnboardingLayout>
|
||||||
|
<InboxChannelsDialog
|
||||||
|
ref="channelsDialogRef"
|
||||||
|
:inboxes="inboxes"
|
||||||
|
@connected="refetchInboxes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, nextTick, onMounted } from 'vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { required } from '@vuelidate/validators';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAlert, useTrack } from 'dashboard/composables';
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { ONBOARDING_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
|
||||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import OnboardingLayout from './OnboardingLayout.vue';
|
import OnboardingLayout from './shared/OnboardingLayout.vue';
|
||||||
import OnboardingSection from './OnboardingSection.vue';
|
import OnboardingSection from './shared/OnboardingSection.vue';
|
||||||
import OnboardingFormRow from './OnboardingFormRow.vue';
|
import OnboardingFormRow from './account-details/OnboardingFormRow.vue';
|
||||||
import OnboardingFormSelect from './OnboardingFormSelect.vue';
|
import OnboardingFormSelect from './account-details/OnboardingFormSelect.vue';
|
||||||
|
import { useAccountEnrichment } from './account-details/useAccountEnrichment';
|
||||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import {
|
import {
|
||||||
@@ -23,12 +24,16 @@ import {
|
|||||||
INDUSTRY_OPTIONS,
|
INDUSTRY_OPTIONS,
|
||||||
REFERRAL_SOURCE_OPTIONS,
|
REFERRAL_SOURCE_OPTIONS,
|
||||||
USER_ROLE_OPTIONS,
|
USER_ROLE_OPTIONS,
|
||||||
} from './constants';
|
} from './shared/constants';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
const { accountId, currentAccount, finishOnboarding } = useAccount();
|
||||||
|
|
||||||
|
// Where each onboarding cursor routes. The backend owns which steps run where;
|
||||||
|
// the frontend just follows the cursor it advanced us to (no deployment checks).
|
||||||
|
const STEP_ROUTES = { inbox_setup: 'onboarding_inbox_setup' };
|
||||||
const { enabledLanguages } = useConfig();
|
const { enabledLanguages } = useConfig();
|
||||||
const currentUser = useMapGetter('getCurrentUser');
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
|
||||||
@@ -46,7 +51,10 @@ const showErrorOnFields = ref(false);
|
|||||||
|
|
||||||
const validationRules = {
|
const validationRules = {
|
||||||
userRole: {},
|
userRole: {},
|
||||||
website: {},
|
// Website is required: the onboarding web-widget inbox can't be created
|
||||||
|
// without a URL (Channel::WebWidget validates presence), so a blank value
|
||||||
|
// would leave the "Live Chat widget" status polling forever.
|
||||||
|
website: { required },
|
||||||
locale: {},
|
locale: {},
|
||||||
timezone: {},
|
timezone: {},
|
||||||
companySize: {},
|
companySize: {},
|
||||||
@@ -67,12 +75,15 @@ const v$ = useVuelidate(validationRules, {
|
|||||||
const userName = computed(() => currentUser.value?.name || '');
|
const userName = computed(() => currentUser.value?.name || '');
|
||||||
const userEmail = computed(() => currentUser.value?.email || '');
|
const userEmail = computed(() => currentUser.value?.email || '');
|
||||||
const accountName = computed(() => currentAccount.value?.name || '');
|
const accountName = computed(() => currentAccount.value?.name || '');
|
||||||
const enrichmentTimedOut = ref(false);
|
const { isEnriching, getChangedFields } = useAccountEnrichment({
|
||||||
const isEnriching = computed(
|
locale,
|
||||||
() =>
|
website,
|
||||||
!enrichmentTimedOut.value &&
|
timezone,
|
||||||
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
companySize,
|
||||||
);
|
industry,
|
||||||
|
referralSource,
|
||||||
|
});
|
||||||
|
|
||||||
const companyLogo = computed(() => {
|
const companyLogo = computed(() => {
|
||||||
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
const logos = currentAccount.value?.custom_attributes?.brand_info?.logos;
|
||||||
if (!logos?.length) return '';
|
if (!logos?.length) return '';
|
||||||
@@ -98,94 +109,10 @@ const timezoneOptions = computed(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Best-effort match browser language to enabled Chatwoot locales.
|
|
||||||
// Tries exact match first (e.g. 'pt_BR'), then base language (e.g. 'pt'),
|
|
||||||
// falls back to account locale or 'en'.
|
|
||||||
const detectBestLocale = () => {
|
|
||||||
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
|
||||||
const browserLang = navigator.language?.replace('-', '_');
|
|
||||||
const accountLocale = currentAccount.value?.locale || 'en';
|
|
||||||
if (!browserLang) return accountLocale;
|
|
||||||
|
|
||||||
if (codes.includes(browserLang)) return browserLang;
|
|
||||||
const base = browserLang.split('_')[0];
|
|
||||||
if (codes.includes(base)) return base;
|
|
||||||
|
|
||||||
return accountLocale;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Snapshot of auto-populated values to detect user edits at submit time
|
|
||||||
const initialValues = ref({});
|
|
||||||
|
|
||||||
const snapshotInitialValues = () => {
|
|
||||||
initialValues.value = {
|
|
||||||
website: website.value,
|
|
||||||
company_size: companySize.value,
|
|
||||||
industry: industry.value,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
|
||||||
// populates untouched fields without clobbering user edits.
|
|
||||||
const populateFormFields = () => {
|
|
||||||
const account = currentAccount.value;
|
|
||||||
const attrs = account?.custom_attributes || {};
|
|
||||||
const brandInfo = attrs.brand_info;
|
|
||||||
|
|
||||||
if (!locale.value) locale.value = detectBestLocale();
|
|
||||||
if (!website.value) {
|
|
||||||
website.value = attrs.website || brandInfo?.domain || '';
|
|
||||||
}
|
|
||||||
if (!timezone.value) {
|
|
||||||
timezone.value =
|
|
||||||
attrs.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
||||||
}
|
|
||||||
if (!companySize.value) companySize.value = attrs.company_size || '';
|
|
||||||
if (!industry.value) {
|
|
||||||
industry.value =
|
|
||||||
attrs.industry || brandInfo?.industries?.[0]?.industry || '';
|
|
||||||
}
|
|
||||||
if (!referralSource.value) referralSource.value = attrs.referral_source || '';
|
|
||||||
|
|
||||||
snapshotInitialValues();
|
|
||||||
};
|
|
||||||
|
|
||||||
let enrichmentTimer = null;
|
|
||||||
|
|
||||||
const startEnrichmentTimer = () => {
|
|
||||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
|
||||||
enrichmentTimer = setTimeout(() => {
|
|
||||||
enrichmentTimedOut.value = true;
|
|
||||||
populateFormFields();
|
|
||||||
}, 30000);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
populateFormFields();
|
|
||||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_VISITED);
|
||||||
if (isEnriching.value) startEnrichmentTimer();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(isEnriching, newVal => {
|
|
||||||
if (newVal) {
|
|
||||||
startEnrichmentTimer();
|
|
||||||
} else {
|
|
||||||
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
|
||||||
populateFormFields();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-populate when account data arrives after mount, or when brand_info
|
|
||||||
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
|
||||||
watch(
|
|
||||||
() => currentAccount.value?.custom_attributes,
|
|
||||||
() => populateFormFields()
|
|
||||||
);
|
|
||||||
|
|
||||||
const enableWebsiteEditing = () => {
|
const enableWebsiteEditing = () => {
|
||||||
isEditingWebsite.value = true;
|
isEditingWebsite.value = true;
|
||||||
nextTick(() => websiteInput.value?.focus());
|
nextTick(() => websiteInput.value?.focus());
|
||||||
@@ -204,8 +131,10 @@ const normalizeWebsiteUrl = raw => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Block submit while enrichment is still running so users can't bypass
|
// Block submit while enrichment is still running so users can't bypass
|
||||||
// the form with empty values — the controller would otherwise clear
|
// the form with empty values — the controller would otherwise clear
|
||||||
// onboarding_step and persist incomplete data.
|
// onboarding_step and persist incomplete data. Also guard against
|
||||||
if (isEnriching.value) return;
|
// re-entry while a submit is in flight (double-click/Enter), which would
|
||||||
|
// fire parallel requests that can duplicate the auto-created inbox/portal.
|
||||||
|
if (isEnriching.value || isSubmitting.value) return;
|
||||||
|
|
||||||
v$.value.$touch();
|
v$.value.$touch();
|
||||||
if (v$.value.$invalid) {
|
if (v$.value.$invalid) {
|
||||||
@@ -214,22 +143,15 @@ const handleSubmit = async () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showErrorOnFields.value = false;
|
showErrorOnFields.value = false;
|
||||||
}, 600);
|
}, 600);
|
||||||
|
// The website field is read-only until edited; open it so a required-but-
|
||||||
|
// empty value is immediately fixable rather than just shaking a locked field.
|
||||||
|
if (v$.value.website.$error) enableWebsiteEditing();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect which enrichable fields the user actually edited *before*
|
// Capture which enrichable fields the user edited *before* normalizing the
|
||||||
// normalizing — otherwise an untouched auto-filled domain
|
// website, so an untouched auto-filled domain isn't falsely flagged.
|
||||||
// (acme.com -> https://acme.com) compares unequal against the raw snapshot
|
const fieldsChanged = getChangedFields();
|
||||||
// and gets falsely reported as changed, skewing onboarding telemetry.
|
|
||||||
const init = initialValues.value;
|
|
||||||
const enrichableFields = {
|
|
||||||
website: website.value,
|
|
||||||
company_size: companySize.value,
|
|
||||||
industry: industry.value,
|
|
||||||
};
|
|
||||||
const fieldsChanged = Object.entries(enrichableFields)
|
|
||||||
.filter(([key, val]) => val !== init[key])
|
|
||||||
.map(([key]) => key);
|
|
||||||
|
|
||||||
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
// Persist with a scheme so downstream consumers (Firecrawl, portal
|
||||||
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
// homepage_link) get a fully-qualified URL regardless of what the user typed.
|
||||||
@@ -246,6 +168,7 @@ const handleSubmit = async () => {
|
|||||||
timezone: timezone.value,
|
timezone: timezone.value,
|
||||||
referral_source: referralSource.value,
|
referral_source: referralSource.value,
|
||||||
user_role: userRole.value,
|
user_role: userRole.value,
|
||||||
|
onboarding_step: 'account_details',
|
||||||
});
|
});
|
||||||
|
|
||||||
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
useTrack(ONBOARDING_EVENTS.ACCOUNT_DETAILS_COMPLETED, {
|
||||||
@@ -260,8 +183,19 @@ const handleSubmit = async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
useAlert(t('ONBOARDING_NEXT.SUCCESS'));
|
||||||
store.commit('RESET_ONBOARDING', accountId.value);
|
// Follow the cursor the backend advanced us to. A next step routes there; no
|
||||||
router.push(frontendURL(`accounts/${accountId.value}/dashboard`));
|
// next step means onboarding is complete, so refresh the user (so the router
|
||||||
|
// guard sees the cleared cursor) and head to the dashboard.
|
||||||
|
const nextStep = currentAccount.value?.custom_attributes?.onboarding_step;
|
||||||
|
if (STEP_ROUTES[nextStep]) {
|
||||||
|
router.push({
|
||||||
|
name: STEP_ROUTES[nextStep],
|
||||||
|
params: { accountId: accountId.value },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await store.dispatch('setUser');
|
||||||
|
router.push({ name: 'home', params: { accountId: accountId.value } });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
useAlert(t('ONBOARDING_NEXT.ERROR'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
|
|
||||||
|
const ENRICHMENT_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// Manages the post-signup enrichment lifecycle for the account-details form.
|
||||||
|
// After signup the account is enriched asynchronously (onboarding_step ===
|
||||||
|
// 'enrichment'); this fills empty form fields from the enriched
|
||||||
|
// custom_attributes/brand_info as it arrives — idempotently, so it never
|
||||||
|
// clobbers a value the user has already typed — waits the step out (with a
|
||||||
|
// timeout fallback), and tracks which enrichable fields the user edited.
|
||||||
|
//
|
||||||
|
// `fields` is the set of form refs to populate, owned by the component so it can
|
||||||
|
// still wire them to validation and the template.
|
||||||
|
export function useAccountEnrichment(fields) {
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
const { enabledLanguages } = useConfig();
|
||||||
|
|
||||||
|
const enrichmentTimedOut = ref(false);
|
||||||
|
const isEnriching = computed(
|
||||||
|
() =>
|
||||||
|
!enrichmentTimedOut.value &&
|
||||||
|
currentAccount.value?.custom_attributes?.onboarding_step === 'enrichment'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Best-effort match browser language to enabled Chatwoot locales: exact match
|
||||||
|
// first (e.g. 'pt_BR'), then base language (e.g. 'pt'), else the account
|
||||||
|
// locale or 'en'.
|
||||||
|
const detectBestLocale = () => {
|
||||||
|
const codes = (enabledLanguages || []).map(l => l.iso_639_1_code);
|
||||||
|
const browserLang = navigator.language?.replace('-', '_');
|
||||||
|
const accountLocale = currentAccount.value?.locale || 'en';
|
||||||
|
if (!browserLang) return accountLocale;
|
||||||
|
|
||||||
|
if (codes.includes(browserLang)) return browserLang;
|
||||||
|
const base = browserLang.split('_')[0];
|
||||||
|
if (codes.includes(base)) return base;
|
||||||
|
|
||||||
|
return accountLocale;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot of the auto-populated values, used to detect user edits at submit.
|
||||||
|
const initialValues = ref({});
|
||||||
|
const snapshotInitialValues = () => {
|
||||||
|
initialValues.value = {
|
||||||
|
website: fields.website.value,
|
||||||
|
company_size: fields.companySize.value,
|
||||||
|
industry: fields.industry.value,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Idempotent: only fills empty fields, so late-arriving enrichment data
|
||||||
|
// populates untouched fields without clobbering user edits.
|
||||||
|
const populateFormFields = () => {
|
||||||
|
const {
|
||||||
|
website,
|
||||||
|
timezone,
|
||||||
|
company_size: companySize,
|
||||||
|
industry,
|
||||||
|
referral_source: referralSource,
|
||||||
|
brand_info: brandInfo,
|
||||||
|
} = currentAccount.value?.custom_attributes || {};
|
||||||
|
|
||||||
|
const fillIfEmpty = (field, value) => {
|
||||||
|
if (!field.value) field.value = value || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
fillIfEmpty(fields.locale, detectBestLocale());
|
||||||
|
fillIfEmpty(fields.website, website || brandInfo?.domain);
|
||||||
|
fillIfEmpty(
|
||||||
|
fields.timezone,
|
||||||
|
timezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
);
|
||||||
|
fillIfEmpty(fields.companySize, companySize);
|
||||||
|
fillIfEmpty(
|
||||||
|
fields.industry,
|
||||||
|
industry || brandInfo?.industries?.[0]?.industry
|
||||||
|
);
|
||||||
|
fillIfEmpty(fields.referralSource, referralSource);
|
||||||
|
|
||||||
|
snapshotInitialValues();
|
||||||
|
};
|
||||||
|
|
||||||
|
let enrichmentTimer = null;
|
||||||
|
const startEnrichmentTimer = () => {
|
||||||
|
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||||
|
enrichmentTimer = setTimeout(() => {
|
||||||
|
enrichmentTimedOut.value = true;
|
||||||
|
populateFormFields();
|
||||||
|
}, ENRICHMENT_TIMEOUT);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
populateFormFields();
|
||||||
|
if (isEnriching.value) startEnrichmentTimer();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isEnriching, enriching => {
|
||||||
|
if (enriching) {
|
||||||
|
startEnrichmentTimer();
|
||||||
|
} else {
|
||||||
|
if (enrichmentTimer) clearTimeout(enrichmentTimer);
|
||||||
|
populateFormFields();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-populate when account data arrives after mount, or when brand_info
|
||||||
|
// appears after enrichment. populateFormFields is idempotent so this is safe.
|
||||||
|
watch(
|
||||||
|
() => currentAccount.value?.custom_attributes,
|
||||||
|
() => populateFormFields()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enrichable fields the user actually edited since they were auto-filled.
|
||||||
|
// Compare against the snapshot *before* the caller normalizes any values —
|
||||||
|
// otherwise an untouched auto-filled domain (acme.com -> https://acme.com)
|
||||||
|
// compares unequal and gets falsely reported as changed.
|
||||||
|
const getChangedFields = () => {
|
||||||
|
const init = initialValues.value;
|
||||||
|
const current = {
|
||||||
|
website: fields.website.value,
|
||||||
|
company_size: fields.companySize.value,
|
||||||
|
industry: fields.industry.value,
|
||||||
|
};
|
||||||
|
return Object.entries(current)
|
||||||
|
.filter(([key, value]) => value !== init[key])
|
||||||
|
.map(([key]) => key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isEnriching, getChangedFields };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
channel: { type: Object, required: true },
|
||||||
|
connectedInbox: { type: Object, default: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['connect']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const connected = computed(() => Boolean(props.connectedInbox));
|
||||||
|
// Prefer the real connected account's name over the detected handle — the user
|
||||||
|
// may have connected a different account than the one we detected.
|
||||||
|
const connectedName = computed(
|
||||||
|
() =>
|
||||||
|
props.connectedInbox?.name ||
|
||||||
|
props.channel.handle ||
|
||||||
|
t(props.channel.labelKey)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2 p-3 border-b border-n-weak">
|
||||||
|
<div class="size-4 rounded overflow-hidden flex-shrink-0">
|
||||||
|
<ChannelIcon
|
||||||
|
:inbox="channel.inbox"
|
||||||
|
use-brand-icon
|
||||||
|
class="size-4"
|
||||||
|
:class="{ grayscale: !connected }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="flex-1 min-w-0 truncate text-body-main text-n-slate-12">
|
||||||
|
{{ t(channel.labelKey) }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="connected"
|
||||||
|
class="flex items-center gap-2 flex-shrink-0 text-body-main text-n-slate-11"
|
||||||
|
>
|
||||||
|
<span class="whitespace-nowrap">{{ connectedName }}</span>
|
||||||
|
<span class="w-px h-3 bg-n-weak" />
|
||||||
|
<span class="whitespace-nowrap">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECTED') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="flex items-center flex-shrink-0 h-7 px-2 rounded-lg outline outline-1 outline-n-container bg-n-button-color text-button text-n-blue-11"
|
||||||
|
@click="$emit('connect', channel)"
|
||||||
|
>
|
||||||
|
<span class="truncate">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.CONNECT') }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup>
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
// Presentational shell for the "Created for you" rows. The owning component
|
||||||
|
// drives `ready` from its own lifecycle (widget polling, help-center events);
|
||||||
|
// row separation is handled by the parent's `divide-y`.
|
||||||
|
defineProps({
|
||||||
|
ready: { type: Boolean, default: false },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
description: { type: String, default: '' },
|
||||||
|
status: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between gap-3 px-3 py-3">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
|
<Icon
|
||||||
|
v-if="ready"
|
||||||
|
icon="i-lucide-check"
|
||||||
|
class="size-4 text-n-teal-11 flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<Spinner v-else :size="16" class="text-n-slate-9 flex-shrink-0" />
|
||||||
|
<span class="text-sm font-medium text-n-slate-12 flex-shrink-0">
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<span class="w-px h-4 bg-n-weak flex-shrink-0" />
|
||||||
|
<span class="text-sm text-n-slate-11 truncate">{{ description }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-n-slate-11 flex-shrink-0">{{ status }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import { useTimeoutPoll } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import OnboardingAPI from 'dashboard/api/onboarding';
|
||||||
|
import CreationStatusRow from './CreationStatusRow.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const POLL_INTERVAL = 5000;
|
||||||
|
|
||||||
|
const generation = ref({
|
||||||
|
generation_id: null,
|
||||||
|
state: null,
|
||||||
|
articles_count: 0,
|
||||||
|
categories_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before the first article arrives, advance through phases so the spinner
|
||||||
|
// label doesn't sit on a single line through the whole curation step.
|
||||||
|
const generatingPhases = computed(() => [
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_GENERATING'),
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ANALYZING_WEBSITE'),
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SETTING_UP_CATEGORIES'),
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CURATING_ARTICLES'),
|
||||||
|
]);
|
||||||
|
const PHASE_DELAY_BASE = 4000;
|
||||||
|
const PHASE_DELAY_JITTER = 2000;
|
||||||
|
|
||||||
|
const phaseIndex = ref(0);
|
||||||
|
let phaseTimer = null;
|
||||||
|
|
||||||
|
// Advance one phase after a jittered delay; stop once we reach the last line
|
||||||
|
// (it then holds until the first article arrives).
|
||||||
|
const scheduleNextPhase = () => {
|
||||||
|
if (phaseIndex.value >= generatingPhases.value.length - 1) return;
|
||||||
|
const delay = PHASE_DELAY_BASE + Math.random() * PHASE_DELAY_JITTER;
|
||||||
|
phaseTimer = setTimeout(() => {
|
||||||
|
phaseIndex.value += 1;
|
||||||
|
scheduleNextPhase();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = computed(
|
||||||
|
() =>
|
||||||
|
generation.value.state?.status ||
|
||||||
|
(generation.value.generation_id ? 'generating' : 'not_started')
|
||||||
|
);
|
||||||
|
const isCompleted = computed(() => status.value === 'completed');
|
||||||
|
const isSkipped = computed(() => status.value === 'skipped');
|
||||||
|
const isNotStarted = computed(() => status.value === 'not_started');
|
||||||
|
const isTerminal = computed(
|
||||||
|
() => isCompleted.value || isSkipped.value || isNotStarted.value
|
||||||
|
);
|
||||||
|
const articlesCount = computed(() => generation.value.articles_count || 0);
|
||||||
|
const categoriesCount = computed(() => generation.value.categories_count || 0);
|
||||||
|
|
||||||
|
// Poll the generation status until it reaches a terminal state. useTimeoutPoll
|
||||||
|
// waits for each request to settle before scheduling the next (so requests never
|
||||||
|
// overlap), fires immediately on mount, and stops on unmount.
|
||||||
|
const { pause: stopPolling } = useTimeoutPoll(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await OnboardingAPI.getHelpCenterGeneration();
|
||||||
|
generation.value = data;
|
||||||
|
if (isTerminal.value) stopPolling();
|
||||||
|
} catch {
|
||||||
|
// Keep polling; transient network failures should not strand onboarding.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
POLL_INTERVAL,
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(scheduleNextPhase);
|
||||||
|
onBeforeUnmount(() => clearTimeout(phaseTimer));
|
||||||
|
|
||||||
|
const articlesText = computed(() =>
|
||||||
|
t(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_ARTICLES',
|
||||||
|
{ count: articlesCount.value },
|
||||||
|
articlesCount.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusText = computed(() => {
|
||||||
|
if (isCompleted.value) {
|
||||||
|
const categories = t(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_CATEGORIES',
|
||||||
|
{ count: categoriesCount.value },
|
||||||
|
categoriesCount.value
|
||||||
|
);
|
||||||
|
return t(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_SUMMARY',
|
||||||
|
{ count: articlesCount.value, categories },
|
||||||
|
articlesCount.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (articlesCount.value > 0) return articlesText.value;
|
||||||
|
return generatingPhases.value[phaseIndex.value];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isVisible = computed(() => !isSkipped.value && !isNotStarted.value);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
|
<template>
|
||||||
|
<CreationStatusRow
|
||||||
|
v-if="isVisible"
|
||||||
|
:ready="isCompleted"
|
||||||
|
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER')"
|
||||||
|
:description="
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.HELP_CENTER_DESCRIPTION')
|
||||||
|
"
|
||||||
|
:status="statusText"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, reactive, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
channel: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['back', 'created']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
// Per-channel field list + payload shape, mirroring the standalone settings
|
||||||
|
// forms (channels/Line.vue, channels/Telegram.vue). Labels/placeholders reuse
|
||||||
|
// the existing INBOX_MGMT translations so there's nothing new to localize.
|
||||||
|
const FORMS = {
|
||||||
|
line: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'lineChannelId',
|
||||||
|
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.LABEL'),
|
||||||
|
placeholder: t(
|
||||||
|
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_ID.PLACEHOLDER'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lineChannelSecret',
|
||||||
|
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.LABEL'),
|
||||||
|
placeholder: t(
|
||||||
|
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_SECRET.PLACEHOLDER'
|
||||||
|
),
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lineChannelToken',
|
||||||
|
label: t('INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.LABEL'),
|
||||||
|
placeholder: t(
|
||||||
|
'INBOX_MGMT.ADD.LINE_CHANNEL.LINE_CHANNEL_TOKEN.PLACEHOLDER'
|
||||||
|
),
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errorMessage: t('INBOX_MGMT.ADD.LINE_CHANNEL.API.ERROR_MESSAGE'),
|
||||||
|
// Inbox name is mandatory for Line; prefill it from the channel label
|
||||||
|
// rather than asking, to keep the form to just the credentials.
|
||||||
|
buildPayload: values => ({
|
||||||
|
name: t(props.channel.labelKey),
|
||||||
|
channel: {
|
||||||
|
type: 'line',
|
||||||
|
line_channel_id: values.lineChannelId,
|
||||||
|
line_channel_secret: values.lineChannelSecret,
|
||||||
|
line_channel_token: values.lineChannelToken,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'botToken',
|
||||||
|
label: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.LABEL'),
|
||||||
|
placeholder: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.BOT_TOKEN.PLACEHOLDER'),
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
errorMessage: t('INBOX_MGMT.ADD.TELEGRAM_CHANNEL.API.ERROR_MESSAGE'),
|
||||||
|
buildPayload: values => ({
|
||||||
|
channel: { type: 'telegram', bot_token: values.botToken },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = FORMS[props.channel.type];
|
||||||
|
const values = reactive(
|
||||||
|
Object.fromEntries(config.fields.map(field => [field.key, '']))
|
||||||
|
);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
|
||||||
|
const isValid = computed(() =>
|
||||||
|
config.fields.every(field => values[field.key].trim())
|
||||||
|
);
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!isValid.value || isCreating.value) return;
|
||||||
|
|
||||||
|
isCreating.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('inboxes/createChannel', config.buildPayload(values));
|
||||||
|
emit('created');
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(error?.message || config.errorMessage);
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- This sits inside the dialog's <form>; intercept Enter so it submits the
|
||||||
|
channel here instead of bubbling up and completing onboarding. -->
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4" @keydown.enter.prevent="submit">
|
||||||
|
<div class="grid grid-cols-[auto_1fr] items-center gap-4">
|
||||||
|
<template v-for="field in config.fields" :key="field.key">
|
||||||
|
<label :for="field.key" class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ field.label }}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
:id="field.key"
|
||||||
|
v-model="values[field.key]"
|
||||||
|
:type="field.type || 'text'"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
blue
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:is-loading="isCreating"
|
||||||
|
:disabled="!isValid"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||||
|
@click="submit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+210
@@ -0,0 +1,210 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
|
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
import { useChannelConnect } from './useChannelConnect';
|
||||||
|
import { useChannelConfig } from './useChannelConfig';
|
||||||
|
import { CHANNEL_LIST } from './constants';
|
||||||
|
import { isChannelConnected } from './channelMatchers';
|
||||||
|
import InboxChannelForm from './InboxChannelForm.vue';
|
||||||
|
import InboxFacebookForm from './InboxFacebookForm.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
inboxes: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['connected']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { connectViaOAuth, connectWhatsapp } = useChannelConnect();
|
||||||
|
const { isConfigured } = useChannelConfig();
|
||||||
|
|
||||||
|
// Maps the dialog's display types to the OAuth client key the flow expects.
|
||||||
|
// Types without an entry (manual-setup channels) are no-ops for now.
|
||||||
|
const OAUTH_PROVIDERS = {
|
||||||
|
[CHANNEL_TYPES.GMAIL]: 'google',
|
||||||
|
[CHANNEL_TYPES.OUTLOOK]: 'microsoft',
|
||||||
|
[CHANNEL_TYPES.INSTAGRAM]: 'instagram',
|
||||||
|
[CHANNEL_TYPES.TIKTOK]: 'tiktok',
|
||||||
|
};
|
||||||
|
|
||||||
|
// A card's availability — what the user can do with it right now:
|
||||||
|
// available — usable now (configured, not deferred)
|
||||||
|
// setupLater — deferred to in-app setup (SMS/API/Voice/Email cards)
|
||||||
|
// Channels whose installation OAuth credentials are missing are hidden entirely
|
||||||
|
// (see channelCards), so they never reach this state.
|
||||||
|
// `connected` (a real inbox already backs it) is orthogonal and tracked
|
||||||
|
// separately, since a connected channel can still be in any of these states.
|
||||||
|
const channelAvailability = channel =>
|
||||||
|
channel.setupLater ? 'setupLater' : 'available';
|
||||||
|
|
||||||
|
const CARD_CLASS = {
|
||||||
|
available: 'bg-n-solid-1 hover:outline-n-slate-6 cursor-pointer',
|
||||||
|
setupLater: 'bg-n-slate-2 cursor-not-allowed',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decorate the catalog with per-render state so the template reads plain fields
|
||||||
|
// rather than calling predicates for each card. Channels needing an absent
|
||||||
|
// installation credential are dropped so they don't show at all; deferred
|
||||||
|
// (setupLater) channels stay since they aren't a configuration problem.
|
||||||
|
const channelCards = computed(() =>
|
||||||
|
CHANNEL_LIST.filter(
|
||||||
|
channel => channel.setupLater || isConfigured(channel.type)
|
||||||
|
).map(channel => {
|
||||||
|
const connected = isChannelConnected(props.inboxes, channel.inbox);
|
||||||
|
// Website inboxes are only auto-created during onboarding — there is no
|
||||||
|
// manual creation path, so an unconnected Website card defers rather than
|
||||||
|
// offering a click that can't do anything.
|
||||||
|
const availability =
|
||||||
|
channel.type === CHANNEL_TYPES.WEBSITE && !connected
|
||||||
|
? 'setupLater'
|
||||||
|
: channelAvailability(channel);
|
||||||
|
return { ...channel, availability, connected };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
|
// Credential-form channels (Line, Telegram) and Facebook swap the grid for an
|
||||||
|
// inline view; OAuth channels redirect; the rest are no-ops for now.
|
||||||
|
const selectedChannel = ref(null);
|
||||||
|
|
||||||
|
// An inbox was created by an in-dialog form (Line/Telegram credentials or the
|
||||||
|
// Facebook page picker); close the dialog (its @close resets the form view) and
|
||||||
|
// let the parent refetch so the connected state and real channel icons update.
|
||||||
|
const onCreated = () => {
|
||||||
|
dialogRef.value?.close();
|
||||||
|
emit('connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCardClick = channel => {
|
||||||
|
if (channel.availability !== 'available') return;
|
||||||
|
if (channel.form) {
|
||||||
|
selectedChannel.value = channel;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// WhatsApp uses Meta's embedded-signup popup, not the redirect OAuth flow.
|
||||||
|
if (channel.type === CHANNEL_TYPES.WHATSAPP) {
|
||||||
|
connectWhatsapp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Facebook swaps to an in-dialog page picker (FB.login → choose a Page).
|
||||||
|
if (channel.type === CHANNEL_TYPES.FACEBOOK) {
|
||||||
|
selectedChannel.value = channel;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connectViaOAuth(OAUTH_PROVIDERS[channel.type]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogTitle = computed(() =>
|
||||||
|
selectedChannel.value
|
||||||
|
? t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_TITLE', {
|
||||||
|
name: t(selectedChannel.value.labelKey),
|
||||||
|
})
|
||||||
|
: t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.TITLE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const dialogDescription = computed(() => {
|
||||||
|
if (!selectedChannel.value) {
|
||||||
|
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SUBTITLE');
|
||||||
|
}
|
||||||
|
if (selectedChannel.value.type === CHANNEL_TYPES.FACEBOOK) {
|
||||||
|
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SUBTITLE');
|
||||||
|
}
|
||||||
|
return t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT_SUBTITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = preselectType => {
|
||||||
|
const entry = preselectType
|
||||||
|
? channelCards.value.find(channel => channel.type === preselectType)
|
||||||
|
: null;
|
||||||
|
// Only jump straight into a channel's view when it's actually usable;
|
||||||
|
// otherwise show the grid (with its muted card) rather than launching SDK
|
||||||
|
// auth with a missing credential.
|
||||||
|
selectedChannel.value = entry?.availability === 'available' ? entry : null;
|
||||||
|
dialogRef.value?.open();
|
||||||
|
};
|
||||||
|
const close = () => dialogRef.value?.close();
|
||||||
|
|
||||||
|
defineExpose({ open, close });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
ref="dialogRef"
|
||||||
|
:title="dialogTitle"
|
||||||
|
:description="dialogDescription"
|
||||||
|
width="lg"
|
||||||
|
:show-confirm-button="false"
|
||||||
|
:show-cancel-button="false"
|
||||||
|
@close="selectedChannel = null"
|
||||||
|
>
|
||||||
|
<InboxFacebookForm
|
||||||
|
v-if="selectedChannel?.type === CHANNEL_TYPES.FACEBOOK"
|
||||||
|
@back="selectedChannel = null"
|
||||||
|
@created="onCreated"
|
||||||
|
/>
|
||||||
|
<InboxChannelForm
|
||||||
|
v-else-if="selectedChannel"
|
||||||
|
:channel="selectedChannel"
|
||||||
|
@back="selectedChannel = null"
|
||||||
|
@created="onCreated"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="channel in channelCards"
|
||||||
|
:key="channel.type"
|
||||||
|
type="button"
|
||||||
|
:disabled="channel.availability !== 'available'"
|
||||||
|
class="flex items-center gap-3 p-3 rounded-xl outline outline-1 outline-n-weak shadow-[0px_1px_2px_0px_rgba(27,28,29,0.036)] transition-colors text-start"
|
||||||
|
:class="CARD_CLASS[channel.availability]"
|
||||||
|
@click="onCardClick(channel)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="size-9 rounded-[10px] outline outline-1 outline-n-weak flex items-center justify-center flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ChannelIcon
|
||||||
|
v-if="channel.inbox"
|
||||||
|
:inbox="channel.inbox"
|
||||||
|
use-brand-icon
|
||||||
|
class="size-5 text-n-slate-11"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else
|
||||||
|
:icon="channel.fallbackIcon"
|
||||||
|
class="size-4 text-n-slate-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="block text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t(channel.labelKey) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="channel.availability === 'setupLater'"
|
||||||
|
class="block text-xs text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.SETUP_LATER') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
v-if="channel.connected"
|
||||||
|
icon="i-lucide-circle-check"
|
||||||
|
class="size-5 text-n-teal-11"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
v-else-if="channel.availability === 'available'"
|
||||||
|
icon="i-lucide-chevron-right"
|
||||||
|
class="size-5 text-n-slate-9"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.NOTE') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n, I18nT } from 'vue-i18n';
|
||||||
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
|
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||||
|
import { FALLBACK_PREVIEW_CHANNELS } from './constants';
|
||||||
|
import { useChannelConfig } from './useChannelConfig';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
remainingChannels: { type: Array, default: () => [] },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['viewAll']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { isConfigured } = useChannelConfig();
|
||||||
|
|
||||||
|
// Icons shown next to "View all". Defaults to the unconnected socials, but when
|
||||||
|
// everything detected is already connected we still want a hint of what's
|
||||||
|
// behind the dialog — fall back to a representative trio. Either way, drop
|
||||||
|
// channels whose installation credentials are missing so we never preview an
|
||||||
|
// icon the dialog itself hides.
|
||||||
|
const previewChannels = computed(() =>
|
||||||
|
(props.remainingChannels.length
|
||||||
|
? props.remainingChannels
|
||||||
|
: FALLBACK_PREVIEW_CHANNELS
|
||||||
|
).filter(channel => isConfigured(channel.type))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2 px-3 pt-3 pb-4">
|
||||||
|
<Icon icon="i-lucide-info" class="size-4 text-n-slate-9 flex-shrink-0" />
|
||||||
|
<I18nT
|
||||||
|
keypath="ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_NOTE"
|
||||||
|
tag="span"
|
||||||
|
class="flex-1 min-w-0 text-body-main text-n-slate-11"
|
||||||
|
>
|
||||||
|
<template #email>
|
||||||
|
<span class="text-n-slate-12">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_EMAIL') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #voice>
|
||||||
|
<span class="text-n-slate-12">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.MORE_CHANNELS_VOICE') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</I18nT>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<ChannelIcon
|
||||||
|
v-for="channel in previewChannels"
|
||||||
|
:key="channel.type"
|
||||||
|
:inbox="channel.inbox"
|
||||||
|
use-brand-icon
|
||||||
|
class="size-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0 ps-1">
|
||||||
|
<span class="w-px h-3 bg-n-weak" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-button text-n-slate-12 hover:underline"
|
||||||
|
@click="$emit('viewAll')"
|
||||||
|
>
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS.VIEW_ALL') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||||
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
|
|
||||||
|
const emit = defineEmits(['back', 'created']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const { isAuthenticating, preloadSdk, loginAndFetchPages } =
|
||||||
|
useFacebookPageConnect();
|
||||||
|
|
||||||
|
// null until the login + page fetch completes.
|
||||||
|
const pages = ref(null);
|
||||||
|
const userAccessToken = ref('');
|
||||||
|
const selectedPageId = ref('');
|
||||||
|
const errored = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
|
||||||
|
const hasFetched = computed(() => pages.value !== null);
|
||||||
|
// Pages already connected as inboxes come back with `exists: true`.
|
||||||
|
const pageOptions = computed(() =>
|
||||||
|
(pages.value || [])
|
||||||
|
.filter(page => !page.exists)
|
||||||
|
.map(page => ({ value: page.id, label: page.name }))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Warm the SDK on open so the login click opens its popup within the gesture's
|
||||||
|
// activation window (see useFacebookPageConnect).
|
||||||
|
onMounted(preloadSdk);
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
errored.value = false;
|
||||||
|
try {
|
||||||
|
const result = await loginAndFetchPages();
|
||||||
|
if (!result) return; // cancelled — stay on the connect prompt
|
||||||
|
userAccessToken.value = result.userAccessToken;
|
||||||
|
pages.value = result.pages;
|
||||||
|
} catch {
|
||||||
|
errored.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInbox = async () => {
|
||||||
|
const page = (pages.value || []).find(p => p.id === selectedPageId.value);
|
||||||
|
if (!page || isCreating.value) return;
|
||||||
|
|
||||||
|
isCreating.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('inboxes/createFBChannel', {
|
||||||
|
user_access_token: userAccessToken.value,
|
||||||
|
page_access_token: page.access_token,
|
||||||
|
page_id: page.id,
|
||||||
|
inbox_name: page.name,
|
||||||
|
});
|
||||||
|
useAlert(t('ONBOARDING_INBOX_SETUP.FACEBOOK_CONNECTED'));
|
||||||
|
emit('created');
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="isAuthenticating"
|
||||||
|
class="flex items-center justify-center gap-3 py-8"
|
||||||
|
>
|
||||||
|
<Spinner :size="16" class="text-n-blue-10" />
|
||||||
|
<span class="text-sm text-n-slate-11">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LOADING') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="hasFetched && pageOptions.length">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE') }}
|
||||||
|
</label>
|
||||||
|
<ComboBox
|
||||||
|
v-model="selectedPageId"
|
||||||
|
:options="pageOptions"
|
||||||
|
:placeholder="
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_SELECT_PAGE')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
blue
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:is-loading="isCreating"
|
||||||
|
:disabled="!selectedPageId"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT')"
|
||||||
|
@click="createInbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="hasFetched">
|
||||||
|
<p class="text-sm text-n-slate-11">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex">
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p v-if="errored" class="text-sm text-n-ruby-11">
|
||||||
|
{{ t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.BACK')"
|
||||||
|
@click="emit('back')"
|
||||||
|
/>
|
||||||
|
<NextButton
|
||||||
|
type="button"
|
||||||
|
blue
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
:label="t('ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH')"
|
||||||
|
@click="connect"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useTimeoutPoll } from '@vueuse/core';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import CreationStatusRow from './CreationStatusRow.vue';
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 5000;
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
// The web widget inbox is created asynchronously during account setup. We poll
|
||||||
|
// the inboxes endpoint until it shows up, then stop — much simpler than the
|
||||||
|
// event-driven help center flow.
|
||||||
|
const websiteInboxes = useMapGetter('inboxes/getWebsiteInboxes');
|
||||||
|
const isReady = computed(() => websiteInboxes.value.length > 0);
|
||||||
|
|
||||||
|
// useTimeoutPoll waits for each fetch to settle before scheduling the next (so
|
||||||
|
// requests never overlap), fires immediately on mount, and stops on unmount.
|
||||||
|
const { pause } = useTimeoutPoll(
|
||||||
|
async () => {
|
||||||
|
await store.dispatch('inboxes/get');
|
||||||
|
if (isReady.value) pause();
|
||||||
|
},
|
||||||
|
POLL_INTERVAL,
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CreationStatusRow
|
||||||
|
:ready="isReady"
|
||||||
|
:title="t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT')"
|
||||||
|
:description="
|
||||||
|
t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_DESCRIPTION')
|
||||||
|
"
|
||||||
|
:status="
|
||||||
|
isReady
|
||||||
|
? t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_READY')
|
||||||
|
: t('ONBOARDING_INBOX_SETUP.CREATED_FOR_YOU.LIVE_CHAT_STATUS')
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
|
// A detected channel maps to a real inbox when they share a channel_type. Gmail
|
||||||
|
// and Outlook both use Channel::Email, so for email we also match on provider.
|
||||||
|
// `stub` is a channel's `{ channel_type, provider }` shape (e.g. channel.inbox).
|
||||||
|
|
||||||
|
// Returns the matching inbox (not a boolean) so callers can show the connected
|
||||||
|
// account's real name rather than the detected handle.
|
||||||
|
export const findConnectedInbox = (inboxes, stub) =>
|
||||||
|
inboxes.find(
|
||||||
|
inbox =>
|
||||||
|
inbox.channel_type === stub?.channel_type &&
|
||||||
|
(stub?.channel_type !== INBOX_TYPES.EMAIL ||
|
||||||
|
inbox.provider === stub?.provider)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isChannelConnected = (inboxes, stub) =>
|
||||||
|
Boolean(stub) && Boolean(findConnectedInbox(inboxes, stub));
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { CHANNEL_TYPES } from 'dashboard/helper/inbox';
|
||||||
|
|
||||||
|
// Channels whose connect flow opens the channels dialog preselected to their
|
||||||
|
// in-dialog step — Facebook (page picker) and the credential-form channels
|
||||||
|
// (Telegram, Line) — rather than redirecting through OAuth.
|
||||||
|
export const DIALOG_CHANNELS = [
|
||||||
|
CHANNEL_TYPES.FACEBOOK,
|
||||||
|
CHANNEL_TYPES.TELEGRAM,
|
||||||
|
CHANNEL_TYPES.LINE,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Suggested channels (in priority order) to offer as rows when nothing is
|
||||||
|
// detected, so the step isn't empty. The mainstream OAuth channels show on
|
||||||
|
// configured installs, while credential-free Telegram/LINE keep the list
|
||||||
|
// non-empty on a bare self-host.
|
||||||
|
export const DEFAULT_CHANNEL_TYPES = [
|
||||||
|
CHANNEL_TYPES.WHATSAPP,
|
||||||
|
CHANNEL_TYPES.FACEBOOK,
|
||||||
|
CHANNEL_TYPES.INSTAGRAM,
|
||||||
|
CHANNEL_TYPES.TELEGRAM,
|
||||||
|
CHANNEL_TYPES.LINE,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Channels offered in the onboarding "View all" dialog. `inbox` is a stub shaped
|
||||||
|
// like a real inbox so ChannelIcon can resolve the icon from the shared provider.
|
||||||
|
// With `use-brand-icon`, ChannelIcon renders the full-color brand logo when one
|
||||||
|
// exists and falls back to the monochrome glyph otherwise, so no per-channel
|
||||||
|
// style flag is needed. Entries without a channel type (Voice, Other Email
|
||||||
|
// Providers) render `fallbackIcon` instead. `form: true` swaps the grid for an
|
||||||
|
// inline credential form; `setupLater: true` defers the channel to in-app setup
|
||||||
|
// for this phase. `labelKey` is an i18n key — most reuse the shared channel
|
||||||
|
// titles from the inbox settings (INBOX_MGMT.ADD.AUTH.CHANNEL.*.TITLE) so the
|
||||||
|
// names translate without duplicating strings; resolve it with `t()` at display.
|
||||||
|
export const CHANNEL_LIST = [
|
||||||
|
{
|
||||||
|
type: 'website',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WEBSITE.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::WebWidget' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'whatsapp',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'instagram',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Instagram' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'facebook',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tiktok',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Tiktok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'telegram',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Telegram' },
|
||||||
|
form: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Line' },
|
||||||
|
form: true,
|
||||||
|
},
|
||||||
|
// Email channels (including Gmail/Outlook OAuth) are set up later in-app for
|
||||||
|
// this phase; they will be enabled in a future PR.
|
||||||
|
{
|
||||||
|
type: 'gmail',
|
||||||
|
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.GMAIL',
|
||||||
|
inbox: { channel_type: 'Channel::Email', provider: 'google' },
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'outlook',
|
||||||
|
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OUTLOOK',
|
||||||
|
inbox: { channel_type: 'Channel::Email', provider: 'microsoft' },
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'sms',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.SMS.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Sms' },
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'api',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.API.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Api' },
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'voice',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.VOICE.TITLE',
|
||||||
|
fallbackIcon: 'i-woot-voice',
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'email',
|
||||||
|
labelKey: 'ONBOARDING_INBOX_SETUP.CHANNELS.OTHER_EMAIL',
|
||||||
|
fallbackIcon: 'i-woot-mail',
|
||||||
|
setupLater: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const channelByType = type =>
|
||||||
|
CHANNEL_LIST.find(channel => channel.type === type);
|
||||||
|
|
||||||
|
// Icons shown next to "View all" when every detected channel is already
|
||||||
|
// connected — a representative trio sourced from CHANNEL_LIST so the inbox stubs
|
||||||
|
// aren't duplicated.
|
||||||
|
export const FALLBACK_PREVIEW_CHANNELS = ['gmail', 'tiktok', 'whatsapp'].map(
|
||||||
|
channelByType
|
||||||
|
);
|
||||||
|
|
||||||
|
// Social channels that detected brand_info socials map to, keyed by social type
|
||||||
|
// in the order they're offered as rows. Derived from CHANNEL_LIST so channel
|
||||||
|
// identity (label, channel_type) has a single source. Keys mirror
|
||||||
|
// SocialLinkParser::SOCIAL_DOMAIN_MAP.
|
||||||
|
const SOCIAL_PLATFORM_TYPES = [
|
||||||
|
'whatsapp',
|
||||||
|
'facebook',
|
||||||
|
'line',
|
||||||
|
'instagram',
|
||||||
|
'telegram',
|
||||||
|
'tiktok',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SOCIAL_PLATFORMS = Object.fromEntries(
|
||||||
|
SOCIAL_PLATFORM_TYPES.map(type => {
|
||||||
|
const { labelKey, inbox } = channelByType(type);
|
||||||
|
return [type, { labelKey, channelType: inbox.channel_type }];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mailbox providers inferred from the signup domain's MX records, keyed by
|
||||||
|
// Channel::Email#provider. Derived from CHANNEL_LIST's email entries.
|
||||||
|
export const EMAIL_PROVIDERS = Object.fromEntries(
|
||||||
|
CHANNEL_LIST.filter(channel => channel.inbox?.provider).map(channel => [
|
||||||
|
channel.inbox.provider,
|
||||||
|
{ labelKey: channel.labelKey },
|
||||||
|
])
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
// OAuth/SDK channels need installation-level app credentials to be usable. When
|
||||||
|
// the credential is missing the channel is "not configured" and is hidden from
|
||||||
|
// onboarding entirely. Channels without an entry (Website, Telegram, Line, …)
|
||||||
|
// need no installation credential and are always considered configured.
|
||||||
|
// Mirrors the availability checks in ChannelItem.vue.
|
||||||
|
export function useChannelConfig() {
|
||||||
|
const globalConfig = useMapGetter('globalConfig/get');
|
||||||
|
const installationConfig = window.chatwootConfig || {};
|
||||||
|
|
||||||
|
const CHANNEL_CONFIGURED = {
|
||||||
|
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
|
||||||
|
// app id (not the 'none' sentinel) and the signup configuration id.
|
||||||
|
whatsapp: () =>
|
||||||
|
Boolean(installationConfig.whatsappAppId) &&
|
||||||
|
installationConfig.whatsappAppId !== 'none' &&
|
||||||
|
Boolean(installationConfig.whatsappConfigurationId),
|
||||||
|
facebook: () => Boolean(installationConfig.fbAppId),
|
||||||
|
instagram: () => Boolean(installationConfig.instagramAppId),
|
||||||
|
tiktok: () => Boolean(installationConfig.tiktokAppId),
|
||||||
|
gmail: () => Boolean(installationConfig.googleOAuthClientId),
|
||||||
|
outlook: () => Boolean(globalConfig.value.azureAppId),
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConfigured = type => CHANNEL_CONFIGURED[type]?.() ?? true;
|
||||||
|
|
||||||
|
return { isConfigured };
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useStore } from 'dashboard/composables/store';
|
||||||
|
import { useWhatsappEmbeddedSignup } from 'dashboard/composables/useWhatsappEmbeddedSignup';
|
||||||
|
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||||
|
import googleClient from 'dashboard/api/channel/googleClient';
|
||||||
|
import microsoftClient from 'dashboard/api/channel/microsoftClient';
|
||||||
|
import instagramClient from 'dashboard/api/channel/instagramClient';
|
||||||
|
import tiktokClient from 'dashboard/api/channel/tiktokClient';
|
||||||
|
|
||||||
|
// Channels that complete via an OAuth redirect. Email channels are keyed by their
|
||||||
|
// Channel::Email provider, others by channel type. The request is tagged with a
|
||||||
|
// return hint so the callback brings the user back to onboarding instead of the
|
||||||
|
// inbox settings page.
|
||||||
|
const OAUTH_CLIENTS = {
|
||||||
|
google: googleClient,
|
||||||
|
microsoft: microsoftClient,
|
||||||
|
instagram: instagramClient,
|
||||||
|
tiktok: tiktokClient,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChannelConnect() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const { runEmbeddedSignup } = useWhatsappEmbeddedSignup();
|
||||||
|
|
||||||
|
const connectViaOAuth = async provider => {
|
||||||
|
const client = OAUTH_CLIENTS[provider];
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { url },
|
||||||
|
} = await client.generateAuthorization({ return_to: 'onboarding' });
|
||||||
|
window.location.href = url;
|
||||||
|
} catch {
|
||||||
|
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// WhatsApp connects via Meta's embedded-signup popup instead of the redirect
|
||||||
|
// OAuth flow above. Collect the signup credentials, exchange them for an
|
||||||
|
// inbox, and surface the result inline — then refetch so the connected state
|
||||||
|
// reflects the freshly created inbox (and renders its real channel icon).
|
||||||
|
const connectWhatsapp = async () => {
|
||||||
|
let credentials;
|
||||||
|
try {
|
||||||
|
credentials = await runEmbeddedSignup();
|
||||||
|
} catch {
|
||||||
|
useAlert(t('ONBOARDING_INBOX_SETUP.ERROR'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!credentials) return; // user dismissed the popup
|
||||||
|
|
||||||
|
try {
|
||||||
|
await store.dispatch('inboxes/createWhatsAppEmbeddedSignup', credentials);
|
||||||
|
await store.dispatch('inboxes/get');
|
||||||
|
useAlert(t('ONBOARDING_INBOX_SETUP.WHATSAPP_CONNECTED'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
parseAPIErrorResponse(error) || t('ONBOARDING_INBOX_SETUP.ERROR')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { connectViaOAuth, connectWhatsapp };
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
|
import {
|
||||||
|
SOCIAL_PLATFORMS,
|
||||||
|
EMAIL_PROVIDERS,
|
||||||
|
DEFAULT_CHANNEL_TYPES,
|
||||||
|
} from './constants';
|
||||||
|
import { findConnectedInbox } from './channelMatchers';
|
||||||
|
import { useChannelConfig } from './useChannelConfig';
|
||||||
|
|
||||||
|
// How many channel rows to show, whether detected or defaulted. DEFAULT_CHANNEL_TYPES
|
||||||
|
// is config-gated like everything else, then sliced to this limit.
|
||||||
|
const DISPLAYED_CHANNEL_LIMIT = 3;
|
||||||
|
|
||||||
|
// Pull the handle/username out of a detected social URL, formatted per channel.
|
||||||
|
const extractHandle = ({ type, url }) => {
|
||||||
|
try {
|
||||||
|
const { pathname } = new URL(url);
|
||||||
|
const path = pathname.replace(/^\/+|\/+$/g, '');
|
||||||
|
if (type === 'whatsapp') {
|
||||||
|
const digits = path.replace(/\D/g, '');
|
||||||
|
return digits ? `+${digits}` : '';
|
||||||
|
}
|
||||||
|
if (type === 'line') return path;
|
||||||
|
return path.startsWith('@') ? path : `@${path}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Derives the channel rows for the inbox-setup step from the account's detected
|
||||||
|
// brand_info (socials + mailbox provider) and the real connected inboxes,
|
||||||
|
// keeping InboxSetup.vue focused on layout, connect routing, and completion.
|
||||||
|
export function useDetectedChannels() {
|
||||||
|
const { currentAccount } = useAccount();
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
const { isConfigured } = useChannelConfig();
|
||||||
|
|
||||||
|
const brandSocials = computed(
|
||||||
|
() => currentAccount.value?.custom_attributes?.brand_info?.socials || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedChannels = computed(() =>
|
||||||
|
brandSocials.value
|
||||||
|
.filter(social => SOCIAL_PLATFORMS[social.type] && social.url)
|
||||||
|
.map(social => ({
|
||||||
|
type: social.type,
|
||||||
|
handle: extractHandle(social),
|
||||||
|
labelKey: SOCIAL_PLATFORMS[social.type].labelKey,
|
||||||
|
inbox: { channel_type: SOCIAL_PLATFORMS[social.type].channelType },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const detectedEmailChannel = computed(() => {
|
||||||
|
const brandInfo = currentAccount.value?.custom_attributes?.brand_info;
|
||||||
|
const provider = brandInfo?.email_provider;
|
||||||
|
if (!EMAIL_PROVIDERS[provider]) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'email',
|
||||||
|
handle: brandInfo?.email || '',
|
||||||
|
labelKey: EMAIL_PROVIDERS[provider].labelKey,
|
||||||
|
inbox: { channel_type: 'Channel::Email', provider },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real inbox backing a channel, if one exists — returned (not just a
|
||||||
|
// boolean) so the row can show the connected account's real name.
|
||||||
|
const connectedInbox = channel =>
|
||||||
|
findConnectedInbox(inboxes.value, channel.inbox);
|
||||||
|
|
||||||
|
// A channel row built from a social type, with no detected handle — used for
|
||||||
|
// the default suggestions when nothing was detected.
|
||||||
|
const toChannelRow = type => ({
|
||||||
|
type,
|
||||||
|
handle: '',
|
||||||
|
labelKey: SOCIAL_PLATFORMS[type].labelKey,
|
||||||
|
inbox: { channel_type: SOCIAL_PLATFORMS[type].channelType },
|
||||||
|
});
|
||||||
|
|
||||||
|
const detectedChannels = computed(() =>
|
||||||
|
[detectedEmailChannel.value, ...connectedChannels.value]
|
||||||
|
.filter(Boolean)
|
||||||
|
// Email channels (including Gmail/Outlook OAuth) are disabled for this
|
||||||
|
// phase; they will be enabled in a future PR.
|
||||||
|
.filter(channel => channel.type !== 'email')
|
||||||
|
// Hide channels whose installation OAuth credentials are missing — their
|
||||||
|
// connect flow would only error.
|
||||||
|
.filter(channel => isConfigured(channel.type))
|
||||||
|
);
|
||||||
|
|
||||||
|
const defaultChannels = computed(() =>
|
||||||
|
DEFAULT_CHANNEL_TYPES.filter(isConfigured)
|
||||||
|
.slice(0, DISPLAYED_CHANNEL_LIMIT)
|
||||||
|
.map(toChannelRow)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show the detected channels, or fall back to the default suggestions so the
|
||||||
|
// step is never an empty list.
|
||||||
|
const displayedChannels = computed(() =>
|
||||||
|
detectedChannels.value.length
|
||||||
|
? detectedChannels.value
|
||||||
|
: defaultChannels.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const remainingChannels = computed(() => {
|
||||||
|
// Exclude whatever is already shown as a row (detected or defaulted) so the
|
||||||
|
// footer preview doesn't duplicate it.
|
||||||
|
const shownTypes = new Set(displayedChannels.value.map(c => c.type));
|
||||||
|
return Object.entries(SOCIAL_PLATFORMS)
|
||||||
|
.filter(([type]) => !shownTypes.has(type))
|
||||||
|
.filter(([type]) => isConfigured(type))
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([type, { labelKey, channelType }]) => ({
|
||||||
|
type,
|
||||||
|
labelKey,
|
||||||
|
inbox: { channel_type: channelType },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDetectedChannels = computed(() => detectedChannels.value.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayedChannels,
|
||||||
|
remainingChannels,
|
||||||
|
connectedInbox,
|
||||||
|
hasDetectedChannels,
|
||||||
|
};
|
||||||
|
}
|
||||||
+27
-12
@@ -5,11 +5,12 @@ defineProps({
|
|||||||
greeting: { type: String, required: true },
|
greeting: { type: String, required: true },
|
||||||
subtitle: { type: String, default: '' },
|
subtitle: { type: String, default: '' },
|
||||||
continueLabel: { type: String, default: 'Continue' },
|
continueLabel: { type: String, default: 'Continue' },
|
||||||
|
skipLabel: { type: String, default: '' },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
disabled: { type: Boolean, default: false },
|
disabled: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['continue']);
|
defineEmits(['continue', 'skip']);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,7 +21,7 @@ defineEmits(['continue']);
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
class="absolute inset-0 bg-[size:96px_96px] bg-[image:linear-gradient(to_right,rgb(var(--border-weak))_1px,transparent_1px),linear-gradient(to_bottom,rgb(var(--border-weak))_1px,transparent_1px)] [mask-image:radial-gradient(ellipse_80%_80%_at_100%_0%,black_5%,transparent_50%),radial-gradient(ellipse_80%_80%_at_0%_100%,black_5%,transparent_50%)] [mask-composite:add] [-webkit-mask-composite:source-over]"
|
||||||
/>
|
/>
|
||||||
<div class="relative w-full max-w-[580px]">
|
<div class="relative w-full max-w-[40rem]">
|
||||||
<div class="relative ps-12">
|
<div class="relative ps-12">
|
||||||
<!-- Timeline dotted line -->
|
<!-- Timeline dotted line -->
|
||||||
<svg
|
<svg
|
||||||
@@ -104,16 +105,30 @@ defineEmits(['continue']);
|
|||||||
>
|
>
|
||||||
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
<path d="M6 0L0 3L6 6Z" fill="rgb(var(--blue-9))" />
|
||||||
</svg>
|
</svg>
|
||||||
<NextButton
|
<div class="flex items-center gap-3">
|
||||||
type="submit"
|
<NextButton
|
||||||
blue
|
type="submit"
|
||||||
:is-loading="isLoading"
|
blue
|
||||||
:disabled="disabled"
|
:is-loading="isLoading"
|
||||||
class="w-full justify-center"
|
:disabled="disabled"
|
||||||
@click="$emit('continue')"
|
:class="
|
||||||
>
|
skipLabel ? 'flex-1 justify-center' : 'w-full justify-center'
|
||||||
{{ continueLabel }}
|
"
|
||||||
</NextButton>
|
@click="$emit('continue')"
|
||||||
|
>
|
||||||
|
{{ continueLabel }}
|
||||||
|
</NextButton>
|
||||||
|
<NextButton
|
||||||
|
v-if="skipLabel"
|
||||||
|
type="button"
|
||||||
|
slate
|
||||||
|
outline
|
||||||
|
class="flex-1 justify-center"
|
||||||
|
@click="$emit('skip')"
|
||||||
|
>
|
||||||
|
{{ skipLabel }}
|
||||||
|
</NextButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
+6
-1
@@ -4,6 +4,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
|
|||||||
defineProps({
|
defineProps({
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
icon: { type: String, required: true },
|
icon: { type: String, required: true },
|
||||||
|
bare: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -42,8 +43,12 @@ defineProps({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1">
|
<div
|
||||||
|
v-if="!bare"
|
||||||
|
class="border border-n-weak rounded-xl overflow-hidden bg-n-surface-1"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
<slot v-else />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
import { defineComponent, h, ref } from 'vue';
|
||||||
|
import { createStore } from 'vuex';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useAccountEnrichment } from '../../account-details/useAccountEnrichment';
|
||||||
|
|
||||||
|
vi.mock('vue-router');
|
||||||
|
|
||||||
|
const ENABLED_LANGUAGES = [
|
||||||
|
{ iso_639_1_code: 'en', name: 'English' },
|
||||||
|
{ iso_639_1_code: 'fr', name: 'French' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mounts the composable against a real store and the real useAccount/useConfig
|
||||||
|
// (only useRoute and the underlying account getter / window config are faked),
|
||||||
|
// so a change to how those resolve their data is exercised here too. `presets`
|
||||||
|
// seeds form fields as if the user had already typed them.
|
||||||
|
const mountComposable = ({
|
||||||
|
account = {},
|
||||||
|
enabledLanguages = ENABLED_LANGUAGES,
|
||||||
|
presets = {},
|
||||||
|
} = {}) => {
|
||||||
|
window.chatwootConfig = { enabledLanguages };
|
||||||
|
|
||||||
|
const store = createStore({
|
||||||
|
modules: {
|
||||||
|
accounts: {
|
||||||
|
namespaced: true,
|
||||||
|
getters: { getAccount: () => () => account },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
locale: ref(presets.locale || ''),
|
||||||
|
website: ref(presets.website || ''),
|
||||||
|
timezone: ref(presets.timezone || ''),
|
||||||
|
companySize: ref(presets.companySize || ''),
|
||||||
|
industry: ref(presets.industry || ''),
|
||||||
|
referralSource: ref(presets.referralSource || ''),
|
||||||
|
};
|
||||||
|
|
||||||
|
let api;
|
||||||
|
const Component = defineComponent({
|
||||||
|
setup() {
|
||||||
|
api = useAccountEnrichment(fields);
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const wrapper = mount(Component, { global: { plugins: [store] } });
|
||||||
|
return { ...api, fields, wrapper };
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.chatwootConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useAccountEnrichment', () => {
|
||||||
|
describe('populateFormFields', () => {
|
||||||
|
it('fills empty fields from the enriched attributes on mount', () => {
|
||||||
|
const { fields } = mountComposable({
|
||||||
|
account: {
|
||||||
|
locale: 'en',
|
||||||
|
custom_attributes: {
|
||||||
|
website: 'https://acme.com',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
company_size: '11-50',
|
||||||
|
industry: 'Technology',
|
||||||
|
referral_source: 'google',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fields.website.value).toBe('https://acme.com');
|
||||||
|
expect(fields.timezone.value).toBe('America/New_York');
|
||||||
|
expect(fields.companySize.value).toBe('11-50');
|
||||||
|
expect(fields.industry.value).toBe('Technology');
|
||||||
|
expect(fields.referralSource.value).toBe('google');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to brand_info for website and industry', () => {
|
||||||
|
const { fields } = mountComposable({
|
||||||
|
account: {
|
||||||
|
custom_attributes: {
|
||||||
|
brand_info: {
|
||||||
|
domain: 'acme.com',
|
||||||
|
industries: [{ industry: 'Retail & E-commerce' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fields.website.value).toBe('acme.com');
|
||||||
|
expect(fields.industry.value).toBe('Retail & E-commerce');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clobber fields the user already set', () => {
|
||||||
|
const { fields } = mountComposable({
|
||||||
|
presets: { website: 'mysite.com', industry: 'Finance' },
|
||||||
|
account: {
|
||||||
|
custom_attributes: {
|
||||||
|
website: 'https://enriched.com',
|
||||||
|
industry: 'Technology',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fields.website.value).toBe('mysite.com');
|
||||||
|
expect(fields.industry.value).toBe('Finance');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects the locale from the browser, else the account locale', () => {
|
||||||
|
// jsdom reports navigator.language as 'en-US' -> base 'en' is enabled.
|
||||||
|
const { fields } = mountComposable({ account: { locale: 'de' } });
|
||||||
|
expect(fields.locale.value).toBe('en');
|
||||||
|
|
||||||
|
// No enabled language matches the browser -> fall back to account locale.
|
||||||
|
const { fields: other } = mountComposable({
|
||||||
|
account: { locale: 'de' },
|
||||||
|
enabledLanguages: [{ iso_639_1_code: 'es', name: 'Spanish' }],
|
||||||
|
});
|
||||||
|
expect(other.locale.value).toBe('de');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEnriching', () => {
|
||||||
|
it('is true while the account is on the enrichment step', () => {
|
||||||
|
const { isEnriching } = mountComposable({
|
||||||
|
account: { custom_attributes: { onboarding_step: 'enrichment' } },
|
||||||
|
});
|
||||||
|
expect(isEnriching.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false on any other step', () => {
|
||||||
|
const { isEnriching } = mountComposable({
|
||||||
|
account: { custom_attributes: { onboarding_step: 'account_details' } },
|
||||||
|
});
|
||||||
|
expect(isEnriching.value).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out after 30s, flipping to false and populating', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const { isEnriching, fields } = mountComposable({
|
||||||
|
account: {
|
||||||
|
custom_attributes: {
|
||||||
|
onboarding_step: 'enrichment',
|
||||||
|
company_size: '51-200',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isEnriching.value).toBe(true);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(30000);
|
||||||
|
|
||||||
|
expect(isEnriching.value).toBe(false);
|
||||||
|
expect(fields.companySize.value).toBe('51-200');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getChangedFields', () => {
|
||||||
|
it('lists only enrichable fields edited after auto-fill', () => {
|
||||||
|
const { fields, getChangedFields } = mountComposable({
|
||||||
|
account: {
|
||||||
|
custom_attributes: {
|
||||||
|
website: 'https://acme.com',
|
||||||
|
company_size: '11-50',
|
||||||
|
industry: 'Technology',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getChangedFields()).toEqual([]);
|
||||||
|
|
||||||
|
fields.industry.value = 'Finance';
|
||||||
|
expect(getChangedFields()).toEqual(['industry']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import HelpCenterCreationStatus from '../../inbox-setup/HelpCenterCreationStatus.vue';
|
||||||
|
import OnboardingAPI from 'dashboard/api/onboarding';
|
||||||
|
|
||||||
|
vi.mock('dashboard/api/onboarding', () => ({
|
||||||
|
default: {
|
||||||
|
getHelpCenterGeneration: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({
|
||||||
|
useI18n: () => ({
|
||||||
|
t: (key, params = {}) => {
|
||||||
|
if (key.endsWith('HELP_CENTER_CATEGORIES')) {
|
||||||
|
return `${params.count} categories`;
|
||||||
|
}
|
||||||
|
if (key.endsWith('HELP_CENTER_SUMMARY')) {
|
||||||
|
return `${params.count} articles across ${params.categories}`;
|
||||||
|
}
|
||||||
|
if (key.endsWith('HELP_CENTER_ARTICLES')) {
|
||||||
|
return `${params.count} articles`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mountStatus = () =>
|
||||||
|
mount(HelpCenterCreationStatus, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
CreationStatusRow: {
|
||||||
|
props: ['ready', 'title', 'description', 'status'],
|
||||||
|
template:
|
||||||
|
'<div data-test="row" :data-ready="ready">{{ status }}</div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HelpCenterCreationStatus', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders completed summary from the status endpoint', async () => {
|
||||||
|
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
generation_id: 'generation-123',
|
||||||
|
state: { status: 'completed' },
|
||||||
|
articles_count: 3,
|
||||||
|
categories_count: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('[data-test="row"]').text()).toBe(
|
||||||
|
'3 articles across 2 categories'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the row when generation is skipped', async () => {
|
||||||
|
OnboardingAPI.getHelpCenterGeneration.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
generation_id: 'generation-123',
|
||||||
|
state: { status: 'skipped' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="row"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('polls while generating and stops after completion', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
OnboardingAPI.getHelpCenterGeneration
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
generation_id: 'generation-123',
|
||||||
|
state: { status: 'generating' },
|
||||||
|
articles_count: 1,
|
||||||
|
categories_count: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
generation_id: 'generation-123',
|
||||||
|
state: { status: 'completed' },
|
||||||
|
articles_count: 2,
|
||||||
|
categories_count: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = mountStatus();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="row"]').text()).toBe('1 articles');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
|
||||||
|
expect(wrapper.find('[data-test="row"]').attributes('data-ready')).toBe(
|
||||||
|
'true'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('[data-test="row"]').text()).toBe(
|
||||||
|
'2 articles across 1 categories'
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(OnboardingAPI.getHelpCenterGeneration).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import InboxChannelsDialog from '../../inbox-setup/InboxChannelsDialog.vue';
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||||
|
vi.mock('dashboard/composables/store', () => ({
|
||||||
|
useMapGetter: () => ({ value: {} }),
|
||||||
|
}));
|
||||||
|
vi.mock('../../inbox-setup/useChannelConnect', () => ({
|
||||||
|
useChannelConnect: () => ({
|
||||||
|
connectViaOAuth: vi.fn(),
|
||||||
|
connectWhatsapp: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mountDialog = () =>
|
||||||
|
mount(InboxChannelsDialog, {
|
||||||
|
props: { inboxes: [] },
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
Dialog: {
|
||||||
|
template: '<div><slot /></div>',
|
||||||
|
methods: { open() {}, close() {} },
|
||||||
|
},
|
||||||
|
InboxFacebookForm: { template: '<div data-test="fb-form" />' },
|
||||||
|
InboxChannelForm: { template: '<div data-test="channel-form" />' },
|
||||||
|
ChannelIcon: true,
|
||||||
|
Icon: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InboxChannelsDialog Facebook gating', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.chatwootConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the Facebook page picker when fbAppId is configured', async () => {
|
||||||
|
window.chatwootConfig = { fbAppId: 'fb-app' };
|
||||||
|
const wrapper = mountDialog();
|
||||||
|
|
||||||
|
wrapper.vm.open('facebook');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the grid (not the picker) when fbAppId is missing', async () => {
|
||||||
|
window.chatwootConfig = {};
|
||||||
|
const wrapper = mountDialog();
|
||||||
|
|
||||||
|
wrapper.vm.open('facebook');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-test="fb-form"]').exists()).toBe(false);
|
||||||
|
// The channel grid renders its cards instead.
|
||||||
|
expect(wrapper.find('button').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils';
|
||||||
|
import { ref, nextTick } from 'vue';
|
||||||
|
import InboxFacebookForm from '../../inbox-setup/InboxFacebookForm.vue';
|
||||||
|
import { useFacebookPageConnect } from 'dashboard/composables/useFacebookPageConnect';
|
||||||
|
|
||||||
|
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: key => key }) }));
|
||||||
|
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
|
||||||
|
vi.mock('dashboard/store/utils/api', () => ({
|
||||||
|
parseAPIErrorResponse: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('dashboard/composables/useFacebookPageConnect', () => ({
|
||||||
|
useFacebookPageConnect: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { dispatch } = vi.hoisted(() => ({ dispatch: vi.fn() }));
|
||||||
|
vi.mock('dashboard/composables/store', () => ({
|
||||||
|
useStore: () => ({ dispatch }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const NextButtonStub = {
|
||||||
|
props: ['label', 'disabled', 'isLoading'],
|
||||||
|
emits: ['click'],
|
||||||
|
template: `<button :disabled="disabled" @click="$emit('click')">{{ label }}</button>`,
|
||||||
|
};
|
||||||
|
const ComboBoxStub = {
|
||||||
|
props: ['modelValue', 'options'],
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
template: '<div data-test="combobox" />',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
{ id: 'p1', name: 'Page One', access_token: 'pt1' },
|
||||||
|
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LAUNCH = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_LAUNCH';
|
||||||
|
const CONNECT = 'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.CONNECT';
|
||||||
|
|
||||||
|
let loginAndFetchPages;
|
||||||
|
let preloadSdk;
|
||||||
|
|
||||||
|
const mountForm = () =>
|
||||||
|
mount(InboxFacebookForm, {
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
NextButton: NextButtonStub,
|
||||||
|
ComboBox: ComboBoxStub,
|
||||||
|
Spinner: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clickButton = (wrapper, label) =>
|
||||||
|
wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find(button => button.text() === label)
|
||||||
|
.trigger('click');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
preloadSdk = vi.fn();
|
||||||
|
loginAndFetchPages = vi.fn();
|
||||||
|
useFacebookPageConnect.mockReturnValue({
|
||||||
|
isAuthenticating: ref(false),
|
||||||
|
preloadSdk,
|
||||||
|
loginAndFetchPages,
|
||||||
|
});
|
||||||
|
dispatch.mockResolvedValue({ id: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InboxFacebookForm', () => {
|
||||||
|
it('preloads the SDK on mount', () => {
|
||||||
|
mountForm();
|
||||||
|
expect(preloadSdk).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists only connectable pages and creates an inbox for the selected one', async () => {
|
||||||
|
loginAndFetchPages.mockResolvedValue({
|
||||||
|
userAccessToken: 'tok',
|
||||||
|
pages: PAGES,
|
||||||
|
});
|
||||||
|
const wrapper = mountForm();
|
||||||
|
|
||||||
|
await clickButton(wrapper, LAUNCH);
|
||||||
|
await flushPromises();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// p2 is already connected (exists), so only p1 is offered.
|
||||||
|
const combobox = wrapper.findComponent(ComboBoxStub);
|
||||||
|
expect(combobox.props('options')).toEqual([
|
||||||
|
{ value: 'p1', label: 'Page One' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
combobox.vm.$emit('update:modelValue', 'p1');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await clickButton(wrapper, CONNECT);
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(dispatch).toHaveBeenCalledWith('inboxes/createFBChannel', {
|
||||||
|
user_access_token: 'tok',
|
||||||
|
page_access_token: 'pt1',
|
||||||
|
page_id: 'p1',
|
||||||
|
inbox_name: 'Page One',
|
||||||
|
});
|
||||||
|
expect(wrapper.emitted('created')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the empty state when every page is already connected', async () => {
|
||||||
|
loginAndFetchPages.mockResolvedValue({
|
||||||
|
userAccessToken: 'tok',
|
||||||
|
pages: [
|
||||||
|
{ id: 'p2', name: 'Page Two', access_token: 'pt2', exists: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const wrapper = mountForm();
|
||||||
|
|
||||||
|
await clickButton(wrapper, LAUNCH);
|
||||||
|
await flushPromises();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_NO_PAGES'
|
||||||
|
);
|
||||||
|
expect(wrapper.find('[data-test="combobox"]').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when the connection fails', async () => {
|
||||||
|
loginAndFetchPages.mockRejectedValue(new Error('boom'));
|
||||||
|
const wrapper = mountForm();
|
||||||
|
|
||||||
|
await clickButton(wrapper, LAUNCH);
|
||||||
|
await flushPromises();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
|
||||||
|
);
|
||||||
|
expect(dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays on the connect prompt without an error when cancelled', async () => {
|
||||||
|
loginAndFetchPages.mockResolvedValue(null);
|
||||||
|
const wrapper = mountForm();
|
||||||
|
|
||||||
|
await clickButton(wrapper, LAUNCH);
|
||||||
|
await flushPromises();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain(
|
||||||
|
'ONBOARDING_INBOX_SETUP.CHANNELS_DIALOG.FACEBOOK_ERROR'
|
||||||
|
);
|
||||||
|
// Launch button is still available to retry.
|
||||||
|
expect(
|
||||||
|
wrapper.findAll('button').some(button => button.text() === LAUNCH)
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
import {
|
||||||
|
findConnectedInbox,
|
||||||
|
isChannelConnected,
|
||||||
|
} from '../../inbox-setup/channelMatchers';
|
||||||
|
|
||||||
|
const WHATSAPP = { id: 1, channel_type: 'Channel::Whatsapp' };
|
||||||
|
const GMAIL = { id: 2, channel_type: 'Channel::Email', provider: 'google' };
|
||||||
|
const OUTLOOK = {
|
||||||
|
id: 3,
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
provider: 'microsoft',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('channelMatchers', () => {
|
||||||
|
describe('findConnectedInbox', () => {
|
||||||
|
it('returns the inbox sharing the channel type', () => {
|
||||||
|
expect(
|
||||||
|
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
|
||||||
|
).toBe(WHATSAPP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches email inboxes on provider', () => {
|
||||||
|
expect(
|
||||||
|
findConnectedInbox([OUTLOOK, GMAIL], {
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
provider: 'google',
|
||||||
|
})
|
||||||
|
).toBe(GMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match a different email provider', () => {
|
||||||
|
expect(
|
||||||
|
findConnectedInbox([OUTLOOK], {
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
provider: 'google',
|
||||||
|
})
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing matches', () => {
|
||||||
|
expect(
|
||||||
|
findConnectedInbox([WHATSAPP], { channel_type: 'Channel::Telegram' })
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isChannelConnected', () => {
|
||||||
|
it('is true when a matching inbox exists', () => {
|
||||||
|
expect(
|
||||||
|
isChannelConnected([WHATSAPP], { channel_type: 'Channel::Whatsapp' })
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when no inbox matches', () => {
|
||||||
|
expect(isChannelConnected([WHATSAPP], GMAIL)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for a channel without an inbox stub', () => {
|
||||||
|
expect(isChannelConnected([WHATSAPP], undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
import { defineComponent, h } from 'vue';
|
||||||
|
import { createStore } from 'vuex';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
|
||||||
|
|
||||||
|
vi.mock('vue-router');
|
||||||
|
|
||||||
|
// Mounts the composable against a real store and the real useAccount (only
|
||||||
|
// useRoute and the underlying getters are faked), so a change to how useAccount
|
||||||
|
// resolves the current account is exercised here too. The real ./constants are
|
||||||
|
// used, so assertions validate against the actual channel identity (label keys,
|
||||||
|
// channel_type, social ordering) derived from CHANNEL_LIST.
|
||||||
|
const mountComposable = ({ brandInfo, inboxes = [] } = {}) => {
|
||||||
|
const store = createStore({
|
||||||
|
modules: {
|
||||||
|
accounts: {
|
||||||
|
namespaced: true,
|
||||||
|
getters: {
|
||||||
|
getAccount: () => () => ({
|
||||||
|
id: 1,
|
||||||
|
custom_attributes: { brand_info: brandInfo },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
inboxes: {
|
||||||
|
namespaced: true,
|
||||||
|
getters: { getInboxes: () => inboxes },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let result;
|
||||||
|
const Component = defineComponent({
|
||||||
|
setup() {
|
||||||
|
result = useDetectedChannels();
|
||||||
|
return () => h('div');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mount(Component, { global: { plugins: [store] } });
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useRoute.mockReturnValue({ params: { accountId: '1' } });
|
||||||
|
// Configure the installation OAuth credentials so detected channels aren't
|
||||||
|
// hidden by the config gate; individual tests clear this to assert hiding.
|
||||||
|
window.chatwootConfig = {
|
||||||
|
fbAppId: 'fb',
|
||||||
|
instagramAppId: 'ig',
|
||||||
|
tiktokAppId: 'tt',
|
||||||
|
whatsappAppId: 'wa',
|
||||||
|
whatsappConfigurationId: 'wa-config',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.chatwootConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDetectedChannels', () => {
|
||||||
|
describe('displayedChannels', () => {
|
||||||
|
it('maps detected socials with a url to channel rows', () => {
|
||||||
|
const { displayedChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
socials: [
|
||||||
|
{ type: 'whatsapp', url: 'https://wa.me/1-415-555-2671' },
|
||||||
|
{ type: 'instagram', url: 'https://instagram.com/acme' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(displayedChannels.value).toEqual([
|
||||||
|
{
|
||||||
|
type: 'whatsapp',
|
||||||
|
handle: '+14155552671',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'instagram',
|
||||||
|
handle: '@acme',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Instagram' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips socials without a url or with an unknown type', () => {
|
||||||
|
const { displayedChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
socials: [
|
||||||
|
{ type: 'telegram' }, // no url
|
||||||
|
{ type: 'mastodon', url: 'https://mastodon.social/@acme' }, // unknown
|
||||||
|
{ type: 'tiktok', url: 'https://tiktok.com/@acme' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||||
|
'tiktok',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the raw path for line and falls back to empty on a bad url', () => {
|
||||||
|
const { displayedChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
socials: [
|
||||||
|
{ type: 'line', url: 'https://line.me/acme' },
|
||||||
|
{ type: 'facebook', url: 'not-a-url' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(displayedChannels.value).toEqual([
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
handle: 'acme',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Line' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'facebook',
|
||||||
|
handle: '',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the detected email channel while email is disabled for this phase', () => {
|
||||||
|
const { displayedChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
email_provider: 'google',
|
||||||
|
email: 'support@acme.com',
|
||||||
|
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||||
|
'whatsapp',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default channel suggestions when nothing is detected', () => {
|
||||||
|
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||||
|
|
||||||
|
// The configured mainstream channels, with no detected handle.
|
||||||
|
expect(displayedChannels.value).toEqual([
|
||||||
|
{
|
||||||
|
type: 'whatsapp',
|
||||||
|
handle: '',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Whatsapp' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'facebook',
|
||||||
|
handle: '',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.FACEBOOK.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::FacebookPage' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'instagram',
|
||||||
|
handle: '',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.INSTAGRAM.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Instagram' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gates the default suggestions by installation config, keeping the list non-empty', () => {
|
||||||
|
window.chatwootConfig = {}; // no OAuth credentials configured
|
||||||
|
const { displayedChannels } = mountComposable({ brandInfo: undefined });
|
||||||
|
|
||||||
|
// Only the credential-free defaults survive (Telegram, LINE).
|
||||||
|
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||||
|
'telegram',
|
||||||
|
'line',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides detected channels whose installation OAuth credentials are missing', () => {
|
||||||
|
window.chatwootConfig = {}; // nothing configured
|
||||||
|
const { displayedChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
socials: [
|
||||||
|
{ type: 'facebook', url: 'https://facebook.com/acme' },
|
||||||
|
{ type: 'line', url: 'https://line.me/acme' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Facebook needs fbAppId (absent → hidden); LINE needs no install credential.
|
||||||
|
expect(displayedChannels.value.map(channel => channel.type)).toEqual([
|
||||||
|
'line',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('remainingChannels', () => {
|
||||||
|
it('returns the platforms not already shown as default rows', () => {
|
||||||
|
// Nothing detected → displayed falls back to the defaults (WhatsApp,
|
||||||
|
// Facebook, Instagram), so the footer previews the remaining platforms.
|
||||||
|
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||||
|
|
||||||
|
expect(remainingChannels.value).toEqual([
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.LINE.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Line' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'telegram',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Telegram' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'tiktok',
|
||||||
|
labelKey: 'INBOX_MGMT.ADD.AUTH.CHANNEL.TIKTOK.TITLE',
|
||||||
|
inbox: { channel_type: 'Channel::Tiktok' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes already-detected socials, preserving order', () => {
|
||||||
|
const { remainingChannels } = mountComposable({
|
||||||
|
brandInfo: {
|
||||||
|
socials: [{ type: 'whatsapp', url: 'https://wa.me/14155552671' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(remainingChannels.value.map(channel => channel.type)).toEqual([
|
||||||
|
'facebook',
|
||||||
|
'line',
|
||||||
|
'instagram',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes channels whose installation OAuth credentials are missing', () => {
|
||||||
|
window.chatwootConfig = {}; // nothing configured
|
||||||
|
const { remainingChannels } = mountComposable({ brandInfo: {} });
|
||||||
|
|
||||||
|
// The only configured channels (Telegram, LINE) are shown as default rows,
|
||||||
|
// and every other platform is gated out — so nothing remains for the footer.
|
||||||
|
expect(remainingChannels.value).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('connectedInbox', () => {
|
||||||
|
it('returns the real inbox sharing the channel type', () => {
|
||||||
|
const inbox = {
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
name: 'WA Biz',
|
||||||
|
};
|
||||||
|
const { connectedInbox } = mountComposable({
|
||||||
|
brandInfo: {},
|
||||||
|
inboxes: [inbox],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
connectedInbox({ inbox: { channel_type: 'Channel::Whatsapp' } })
|
||||||
|
).toBe(inbox);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches email inboxes on provider', () => {
|
||||||
|
const gmail = {
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
provider: 'google',
|
||||||
|
};
|
||||||
|
const outlook = {
|
||||||
|
id: 2,
|
||||||
|
channel_type: 'Channel::Email',
|
||||||
|
provider: 'microsoft',
|
||||||
|
};
|
||||||
|
const { connectedInbox } = mountComposable({
|
||||||
|
brandInfo: {},
|
||||||
|
inboxes: [outlook, gmail],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
connectedInbox({
|
||||||
|
inbox: { channel_type: 'Channel::Email', provider: 'google' },
|
||||||
|
})
|
||||||
|
).toBe(gmail);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing matches', () => {
|
||||||
|
const { connectedInbox } = mountComposable({
|
||||||
|
brandInfo: {},
|
||||||
|
inboxes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
connectedInbox({ inbox: { channel_type: 'Channel::Telegram' } })
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-2
@@ -5,7 +5,7 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
import TextArea from 'next/textarea/TextArea.vue';
|
import Editor from 'next/Editor/Editor.vue';
|
||||||
import Switch from 'next/switch/Switch.vue';
|
import Switch from 'next/switch/Switch.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
import DurationInput from 'next/input/DurationInput.vue';
|
import DurationInput from 'next/input/DurationInput.vue';
|
||||||
@@ -162,9 +162,13 @@ const toggleAutoResolve = async () => {
|
|||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
|
||||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
|
||||||
>
|
>
|
||||||
<TextArea
|
<Editor
|
||||||
v-model="message"
|
v-model="message"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
channel-type="Context::NoToolbar"
|
||||||
|
enable-variables
|
||||||
|
:enable-canned-responses="false"
|
||||||
|
:show-character-count="false"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting';
|
|||||||
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
|
export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100;
|
||||||
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
|
export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600;
|
||||||
|
|
||||||
|
// Default age threshold for excluding stale unassigned conversations (7 days)
|
||||||
|
export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168;
|
||||||
|
|
||||||
// Options groupings
|
// Options groupings
|
||||||
export const OPTIONS = {
|
export const OPTIONS = {
|
||||||
ORDER: [ROUND_ROBIN, BALANCED],
|
ORDER: [ROUND_ROBIN, BALANCED],
|
||||||
|
|||||||
+1
@@ -106,6 +106,7 @@ const formData = computed(() => ({
|
|||||||
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
selectedPolicy.value?.conversationPriority || EARLIEST_CREATED,
|
||||||
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100,
|
||||||
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600,
|
||||||
|
excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleDeleteInbox = async inboxId => {
|
const handleDeleteInbox = async inboxId => {
|
||||||
|
|||||||
+52
-1
@@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
|
|||||||
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue';
|
||||||
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue';
|
||||||
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue';
|
||||||
|
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||||
|
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||||
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
EARLIEST_CREATED,
|
EARLIEST_CREATED,
|
||||||
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||||
|
DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||||
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
|
} from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -28,6 +31,7 @@ const props = defineProps({
|
|||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||||
|
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
mode: {
|
mode: {
|
||||||
@@ -56,7 +60,6 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
'submit',
|
'submit',
|
||||||
'addInbox',
|
'addInbox',
|
||||||
@@ -64,6 +67,9 @@ const emit = defineEmits([
|
|||||||
'navigateToInbox',
|
'navigateToInbox',
|
||||||
'validationChange',
|
'validationChange',
|
||||||
]);
|
]);
|
||||||
|
// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes)
|
||||||
|
const MIN_EXCLUSION_MINUTES = 60;
|
||||||
|
const MAX_EXCLUSION_MINUTES = 1438560;
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -83,12 +89,28 @@ const state = reactive({
|
|||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||||
|
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const validationState = ref({
|
const validationState = ref({
|
||||||
isValid: false,
|
isValid: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exclusionUnit = ref(DURATION_UNITS.DAYS);
|
||||||
|
|
||||||
|
// DurationInput works in minutes; the policy stores hours, so bridge the two
|
||||||
|
const excludeOlderThanMinutes = computed({
|
||||||
|
get() {
|
||||||
|
return state.excludeOlderThanHours == null
|
||||||
|
? null
|
||||||
|
: state.excludeOlderThanHours * 60;
|
||||||
|
},
|
||||||
|
set(minutes) {
|
||||||
|
state.excludeOlderThanHours =
|
||||||
|
minutes == null ? null : Math.round(minutes / 60);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const createOption = (
|
const createOption = (
|
||||||
type,
|
type,
|
||||||
key,
|
key,
|
||||||
@@ -170,6 +192,7 @@ const resetForm = () => {
|
|||||||
conversationPriority: EARLIEST_CREATED,
|
conversationPriority: EARLIEST_CREATED,
|
||||||
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT,
|
||||||
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW,
|
||||||
|
excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -177,10 +200,17 @@ const handleSubmit = () => {
|
|||||||
emit('submit', { ...state });
|
emit('submit', { ...state });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored
|
||||||
|
const detectExclusionUnit = hours => {
|
||||||
|
exclusionUnit.value =
|
||||||
|
hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS;
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.initialData,
|
() => props.initialData,
|
||||||
newData => {
|
newData => {
|
||||||
Object.assign(state, newData);
|
Object.assign(state, newData);
|
||||||
|
detectExclusionUnit(newData.excludeOlderThanHours);
|
||||||
},
|
},
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
);
|
);
|
||||||
@@ -247,6 +277,27 @@ defineExpose({
|
|||||||
v-model:window-unit="state.windowUnit"
|
v-model:window-unit="state.windowUnit"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 pb-2 flex-col flex gap-4">
|
||||||
|
<div class="flex flex-col items-start gap-1 py-1">
|
||||||
|
<label class="text-sm font-medium text-n-slate-12 py-1">
|
||||||
|
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.LABEL`) }}
|
||||||
|
</label>
|
||||||
|
<p class="mb-0 text-n-slate-11 text-sm">
|
||||||
|
{{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 [&>select]:!bg-n-alpha-2 [&>select]:!outline-none [&>select]:hover:brightness-110"
|
||||||
|
>
|
||||||
|
<DurationInput
|
||||||
|
v-model:unit="exclusionUnit"
|
||||||
|
v-model:model-value="excludeOlderThanMinutes"
|
||||||
|
:min="MIN_EXCLUSION_MINUTES"
|
||||||
|
:max="MAX_EXCLUSION_MINUTES"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -149,9 +149,5 @@ export default {
|
|||||||
|
|
||||||
:deep(.ProseMirror-woot-style) {
|
:deep(.ProseMirror-woot-style) {
|
||||||
@apply min-h-[12.5rem];
|
@apply min-h-[12.5rem];
|
||||||
|
|
||||||
p {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -153,9 +153,5 @@ export default {
|
|||||||
|
|
||||||
:deep(.ProseMirror-woot-style) {
|
:deep(.ProseMirror-woot-style) {
|
||||||
@apply min-h-[12.5rem];
|
@apply min-h-[12.5rem];
|
||||||
|
|
||||||
p {
|
|
||||||
@apply text-base;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ const showWebhookSection = computed(
|
|||||||
() => props.healthData?.webhook_configuration !== undefined
|
() => props.healthData?.webhook_configuration !== undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Phone-level override takes precedence over WABA-level (application), so prefer it.
|
||||||
const webhookUrl = computed(
|
const webhookUrl = computed(
|
||||||
() =>
|
() =>
|
||||||
|
props.healthData?.webhook_configuration?.phone_number ||
|
||||||
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
props.healthData?.webhook_configuration?.whatsapp_business_account ||
|
||||||
props.healthData?.webhook_configuration?.application
|
props.healthData?.webhook_configuration?.application
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ export default {
|
|||||||
summary-fetching-key="getBotSummaryFetchingStatus"
|
summary-fetching-key="getBotSummaryFetchingStatus"
|
||||||
:group-by="groupBy"
|
:group-by="groupBy"
|
||||||
:report-keys="reportKeys"
|
:report-keys="reportKeys"
|
||||||
|
:from="from"
|
||||||
|
:to="to"
|
||||||
|
:business-hours="businessHours"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ export default {
|
|||||||
show-group-by
|
show-group-by
|
||||||
@filter-change="onFilterChange"
|
@filter-change="onFilterChange"
|
||||||
/>
|
/>
|
||||||
<ReportContainer :group-by="groupBy" />
|
<ReportContainer
|
||||||
|
:group-by="groupBy"
|
||||||
|
:from="from"
|
||||||
|
:to="to"
|
||||||
|
:business-hours="businessHours"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
|
|||||||
import fromUnixTime from 'date-fns/fromUnixTime';
|
import fromUnixTime from 'date-fns/fromUnixTime';
|
||||||
import format from 'date-fns/format';
|
import format from 'date-fns/format';
|
||||||
import { formatTime } from '@chatwoot/utils';
|
import { formatTime } from '@chatwoot/utils';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
import ChartStats from './components/ChartElements/ChartStats.vue';
|
import ChartStats from './components/ChartElements/ChartStats.vue';
|
||||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||||
|
import ReportDrilldownDrawer from './components/ReportDrilldownDrawer.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { ChartStats, BarChart },
|
components: { ChartStats, BarChart, ReportDrilldownDrawer },
|
||||||
props: {
|
props: {
|
||||||
groupBy: {
|
groupBy: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
|
from: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
reportType: {
|
||||||
|
type: String,
|
||||||
|
default: 'account',
|
||||||
|
},
|
||||||
|
selectedItemId: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
businessHours: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
accountSummaryKey: {
|
accountSummaryKey: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'getAccountSummary',
|
default: 'getAccountSummary',
|
||||||
@@ -42,10 +64,27 @@ export default {
|
|||||||
);
|
);
|
||||||
return { calculateTrend, isAverageMetricType };
|
return { calculateTrend, isAverageMetricType };
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
drilldownRequest: null,
|
||||||
|
drilldownMetric: null,
|
||||||
|
drilldownIndex: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
accountReport: 'getAccountReports',
|
accountReport: 'getAccountReports',
|
||||||
|
currentRole: 'getCurrentRole',
|
||||||
}),
|
}),
|
||||||
|
isAdmin() {
|
||||||
|
return this.currentRole === 'administrator';
|
||||||
|
},
|
||||||
|
canDrilldownPrev() {
|
||||||
|
return this.findDrillableIndex(this.drilldownIndex - 1, -1) !== null;
|
||||||
|
},
|
||||||
|
canDrilldownNext() {
|
||||||
|
return this.findDrillableIndex(this.drilldownIndex + 1, 1) !== null;
|
||||||
|
},
|
||||||
metrics() {
|
metrics() {
|
||||||
const reportKeys = Object.keys(this.reportKeys);
|
const reportKeys = Object.keys(this.reportKeys);
|
||||||
const infoText = {
|
const infoText = {
|
||||||
@@ -139,6 +178,82 @@ export default {
|
|||||||
|
|
||||||
return options;
|
return options;
|
||||||
},
|
},
|
||||||
|
isDrilldownEnabled() {
|
||||||
|
return !!(this.from && this.to);
|
||||||
|
},
|
||||||
|
onChartElementClick(metric, event) {
|
||||||
|
if (!this.isDrilldownEnabled()) return;
|
||||||
|
|
||||||
|
const dataPoint = this.accountReport.data[metric.KEY]?.[event.dataIndex];
|
||||||
|
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||||
|
if (!this.isAdmin) {
|
||||||
|
useAlert(this.$t('REPORT.DRILLDOWN.ADMIN_ONLY'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.openDrilldownAt(metric, event.dataIndex);
|
||||||
|
},
|
||||||
|
openDrilldownAt(metric, dataIndex) {
|
||||||
|
const dataPoint = this.accountReport.data[metric.KEY]?.[dataIndex];
|
||||||
|
if (!this.canOpenDrilldown(metric, dataPoint)) return;
|
||||||
|
|
||||||
|
const labels = this.getCollection(metric).labels || [];
|
||||||
|
|
||||||
|
this.drilldownMetric = metric;
|
||||||
|
this.drilldownIndex = dataIndex;
|
||||||
|
this.drilldownRequest = {
|
||||||
|
metric: metric.KEY,
|
||||||
|
metricName: metric.NAME,
|
||||||
|
bucketLabel: labels[dataIndex],
|
||||||
|
bucketTimestamp: dataPoint.timestamp,
|
||||||
|
bucketValue: dataPoint.value,
|
||||||
|
isAverageMetric: this.isAverageMetricType(metric.KEY),
|
||||||
|
from: this.from,
|
||||||
|
to: this.to,
|
||||||
|
type: this.reportType,
|
||||||
|
id: this.selectedItemId,
|
||||||
|
groupBy: this.groupBy?.period,
|
||||||
|
businessHours: this.businessHours,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
navigateDrilldown(direction) {
|
||||||
|
const nextIndex = this.findDrillableIndex(
|
||||||
|
this.drilldownIndex + direction,
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
if (nextIndex === null) return;
|
||||||
|
|
||||||
|
this.openDrilldownAt(this.drilldownMetric, nextIndex);
|
||||||
|
},
|
||||||
|
findDrillableIndex(startIndex, step) {
|
||||||
|
if (!this.drilldownMetric) return null;
|
||||||
|
|
||||||
|
const data = this.accountReport.data[this.drilldownMetric.KEY] || [];
|
||||||
|
for (
|
||||||
|
let index = startIndex;
|
||||||
|
index >= 0 && index < data.length;
|
||||||
|
index += step
|
||||||
|
) {
|
||||||
|
if (this.canOpenDrilldown(this.drilldownMetric, data[index]))
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
canOpenDrilldown(metric, dataPoint) {
|
||||||
|
if (!dataPoint) return false;
|
||||||
|
|
||||||
|
if (this.isAverageMetricType(metric.KEY)) {
|
||||||
|
return dataPoint.count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataPoint.value > 0;
|
||||||
|
},
|
||||||
|
closeDrilldown() {
|
||||||
|
this.drilldownRequest = null;
|
||||||
|
this.drilldownMetric = null;
|
||||||
|
this.drilldownIndex = null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -168,6 +283,8 @@ export default {
|
|||||||
v-if="accountReport.data[metric.KEY].length"
|
v-if="accountReport.data[metric.KEY].length"
|
||||||
:collection="getCollection(metric)"
|
:collection="getCollection(metric)"
|
||||||
:chart-options="getChartOptions(metric)"
|
:chart-options="getChartOptions(metric)"
|
||||||
|
:clickable="isDrilldownEnabled()"
|
||||||
|
@element-click="onChartElementClick(metric, $event)"
|
||||||
/>
|
/>
|
||||||
<span v-else class="text-sm text-n-slate-10">
|
<span v-else class="text-sm text-n-slate-10">
|
||||||
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
{{ $t('REPORT.NO_ENOUGH_DATA') }}
|
||||||
@@ -176,4 +293,23 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ReportDrilldownDrawer
|
||||||
|
:id="drilldownRequest?.id"
|
||||||
|
:open="!!drilldownRequest"
|
||||||
|
:metric="drilldownRequest?.metric"
|
||||||
|
:metric-name="drilldownRequest?.metricName"
|
||||||
|
:bucket-label="drilldownRequest?.bucketLabel"
|
||||||
|
:bucket-timestamp="drilldownRequest?.bucketTimestamp"
|
||||||
|
:bucket-value="drilldownRequest?.bucketValue"
|
||||||
|
:is-average-metric="drilldownRequest?.isAverageMetric"
|
||||||
|
:from="drilldownRequest?.from"
|
||||||
|
:to="drilldownRequest?.to"
|
||||||
|
:type="drilldownRequest?.type"
|
||||||
|
:group-by="drilldownRequest?.groupBy"
|
||||||
|
:business-hours="drilldownRequest?.businessHours"
|
||||||
|
:can-prev="canDrilldownPrev"
|
||||||
|
:can-next="canDrilldownNext"
|
||||||
|
@navigate="navigateDrilldown"
|
||||||
|
@close="closeDrilldown"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user