Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1670567bb2 | ||
|
|
bdcb1934c0 | ||
|
|
4f09c2203c | ||
|
|
f455e7994e | ||
|
|
28e16f7ee0 | ||
|
|
fb0be60ae2 | ||
|
|
3c9c0298ba | ||
|
|
d6af182f82 | ||
|
|
c07d03e4e5 | ||
|
|
e81152608d | ||
|
|
615e81731c | ||
|
|
fb1aa085cf | ||
|
|
e8a01ace41 | ||
|
|
682a931707 | ||
|
|
01363042ce | ||
|
|
5bf39d20e5 | ||
|
|
90352b3a20 | ||
|
|
ba8df900e3 | ||
|
|
7aa3adc4a3 | ||
|
|
9b75d9bd1b | ||
|
|
ec6c3b3571 | ||
|
|
48ba273730 | ||
|
|
5491ca2470 | ||
|
|
72391f9c36 | ||
|
|
f89d9a4401 | ||
|
|
e8ae73230d | ||
|
|
40c75941f5 | ||
|
|
d9b840f161 | ||
|
|
1fbdd68222 | ||
|
|
ef54f07d5b | ||
|
|
e771d99552 | ||
|
|
358a3924b8 | ||
|
|
6b87d6784e | ||
|
|
faaf67129e | ||
|
|
159c810117 | ||
|
|
ce400a36d7 | ||
|
|
d20de25da1 | ||
|
|
c31d693add | ||
|
|
31497d9c63 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c | ||
|
|
ee1ea9576b | ||
|
|
26ea87a6cb | ||
|
|
7e8fe78ecd | ||
|
|
f3176afc1c | ||
|
|
c4ba925ae7 | ||
|
|
1f0b56b96e | ||
|
|
e7177364d4 | ||
|
|
f726dc2419 | ||
|
|
5891fd6f49 | ||
|
|
b9864fe1f6 | ||
|
|
5f97a2fd7b | ||
|
|
74fc9c0321 | ||
|
|
9898ccee9e | ||
|
|
eabdfc8168 | ||
|
|
9bd5f15450 | ||
|
|
aea1585fa3 | ||
|
|
254d5dcf9a | ||
|
|
b4c4f328b2 | ||
|
|
89576a6475 | ||
|
|
a509ef826a | ||
|
|
2180edc14a | ||
|
|
de2a35b617 | ||
|
|
40577e6a50 | ||
|
|
307dbc4202 | ||
|
|
368d7c4608 | ||
|
|
f1f1ce644c | ||
|
|
38f16ba677 | ||
|
|
e7b01d80b3 | ||
|
|
ec9a82a017 | ||
|
|
cdd3b73fc9 | ||
|
|
610495123e | ||
|
|
cb65d615ea | ||
|
|
d8da1f5bf3 | ||
|
|
6829328182 | ||
|
|
6cc69f444b | ||
|
|
f89ed56258 | ||
|
|
7c5bb343c6 | ||
|
|
170ea7691f | ||
|
|
606adffeeb | ||
|
|
e9c1c61fe4 | ||
|
|
78ebdbbbd8 | ||
|
|
978f4c431a | ||
|
|
f34a20e12a | ||
|
|
4b2ebb8877 | ||
|
|
c4c1f3eb63 | ||
|
|
5e1680e840 | ||
|
|
7bbedf2b6a | ||
|
|
a7d70f418b | ||
|
|
38873fcfc2 | ||
|
|
6cf8a6b7f8 | ||
|
|
26ec0ebd09 | ||
|
|
d248f0bea2 | ||
|
|
1caec05517 |
@@ -256,6 +256,8 @@ AZURE_APP_SECRET=
|
||||
## Change these values to fine tune performance
|
||||
# control the concurrency setting of sidekiq
|
||||
# SIDEKIQ_CONCURRENCY=10
|
||||
# Enable verbose logging each time a job is dequeued in Sidekiq
|
||||
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||
|
||||
|
||||
# AI powered features
|
||||
|
||||
@@ -70,6 +70,7 @@ jobs:
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@ Metrics/MethodLength:
|
||||
- 'enterprise/lib/captain/agent.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Max: 50
|
||||
|
||||
Style/Documentation:
|
||||
Enabled: false
|
||||
@@ -336,4 +336,4 @@ FactoryBot/RedundantFactoryOption:
|
||||
Enabled: false
|
||||
|
||||
FactoryBot/FactoryAssociationWithStrategy:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
|
||||
@@ -21,6 +21,7 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'email-provider-info'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
+6
-4
@@ -270,6 +270,7 @@ GEM
|
||||
concurrent-ruby (~> 1.0)
|
||||
http (>= 3.0)
|
||||
ruby2_keywords
|
||||
email-provider-info (0.0.1)
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
@@ -594,7 +595,7 @@ GEM
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
@@ -644,7 +645,7 @@ GEM
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.0)
|
||||
rack (3.2.3)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.5.0)
|
||||
@@ -653,7 +654,7 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
@@ -935,7 +936,7 @@ GEM
|
||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
||||
unicode-emoji (4.0.4)
|
||||
uniform_notifier (1.17.0)
|
||||
uri (1.0.3)
|
||||
uri (1.0.4)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
@@ -1016,6 +1017,7 @@ DEPENDENCIES
|
||||
dotenv-rails (>= 3.0.0)
|
||||
down
|
||||
elastic-apm
|
||||
email-provider-info
|
||||
email_reply_trimmer
|
||||
facebook-messenger
|
||||
factory_bot_rails (>= 6.4.3)
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
},
|
||||
"NODE_OPTIONS": {
|
||||
"description": "Increase V8 heap for Vite build to avoid OOM",
|
||||
"value": "--max-old-space-size=4096"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
class Messages::MessageBuilder
|
||||
include ::FileTypeHelper
|
||||
include ::EmailHelper
|
||||
include ::DataHelper
|
||||
|
||||
attr_reader :message
|
||||
|
||||
def initialize(user, conversation, params)
|
||||
@@ -23,7 +26,7 @@ class Messages::MessageBuilder
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
||||
process_email_content
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
|
||||
params = convert_to_hash(@params)
|
||||
content_attributes = params.fetch(:content_attributes, {})
|
||||
|
||||
return parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
|
||||
return content_attributes if content_attributes.is_a?(Hash)
|
||||
|
||||
{}
|
||||
end
|
||||
|
||||
# Converts the given object to a hash.
|
||||
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
|
||||
# Otherwise, returns the object as-is.
|
||||
def convert_to_hash(obj)
|
||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||
|
||||
obj
|
||||
end
|
||||
|
||||
# Attempts to parse a string as JSON.
|
||||
# If successful, returns the parsed hash with symbolized names.
|
||||
# If unsuccessful, returns nil.
|
||||
def parse_json(content)
|
||||
JSON.parse(content, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
return if @attachments.blank?
|
||||
|
||||
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
|
||||
email_string.gsub(/\s+/, '').split(',')
|
||||
end
|
||||
|
||||
def validate_email_addresses(all_emails)
|
||||
all_emails&.each do |email|
|
||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
|
||||
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
|
||||
@@ -178,8 +157,17 @@ class Messages::MessageBuilder
|
||||
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
|
||||
normalized_content = normalize_email_body(@message.content)
|
||||
|
||||
email_attributes[:html_content] = build_html_content(normalized_content)
|
||||
email_attributes[:text_content] = build_text_content(normalized_content)
|
||||
# Process liquid templates in normalized content with code block protection
|
||||
processed_content = process_liquid_in_email_body(normalized_content)
|
||||
|
||||
# Use custom HTML content if provided, otherwise generate from message content
|
||||
email_attributes[:html_content] = if custom_email_content_provided?
|
||||
build_custom_html_content
|
||||
else
|
||||
build_html_content(processed_content)
|
||||
end
|
||||
|
||||
email_attributes[:text_content] = build_text_content(processed_content)
|
||||
email_attributes
|
||||
end
|
||||
|
||||
@@ -198,19 +186,39 @@ class Messages::MessageBuilder
|
||||
text_content
|
||||
end
|
||||
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
def custom_email_content_provided?
|
||||
@params[:email_html_content].present?
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
def build_custom_html_content
|
||||
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
|
||||
|
||||
html_content[:full] = @params[:email_html_content]
|
||||
html_content[:reply] = @params[:email_html_content]
|
||||
|
||||
html_content
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
# Liquid processing methods for email content
|
||||
def process_liquid_in_email_body(content)
|
||||
return content if content.blank?
|
||||
return content unless should_process_liquid?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
# Protect code blocks from liquid processing
|
||||
modified_content = modified_liquid_content(content)
|
||||
template = Liquid::Template.parse(modified_content)
|
||||
template.render(drops_with_sender)
|
||||
rescue Liquid::Error
|
||||
content
|
||||
end
|
||||
|
||||
def should_process_liquid?
|
||||
@message_type == 'outgoing' || @message_type == 'template'
|
||||
end
|
||||
|
||||
def drops_with_sender
|
||||
message_drops(@conversation).merge({
|
||||
'agent' => UserDrop.new(sender)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,10 +10,28 @@ class V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def load_reporting_events_data
|
||||
# Extract the column name for indexing (e.g., 'conversations.team_id' -> 'team_id')
|
||||
index_key = group_by_key.to_s.split('.').last
|
||||
|
||||
results = reporting_events
|
||||
.select(
|
||||
"#{group_by_key} as #{index_key}",
|
||||
"COUNT(CASE WHEN name = 'conversation_resolved' THEN 1 END) as resolved_count",
|
||||
"AVG(CASE WHEN name = 'conversation_resolved' THEN #{average_value_key} END) as avg_resolution_time",
|
||||
"AVG(CASE WHEN name = 'first_response' THEN #{average_value_key} END) as avg_first_response_time",
|
||||
"AVG(CASE WHEN name = 'reply_time' THEN #{average_value_key} END) as avg_reply_time"
|
||||
)
|
||||
.group(group_by_key)
|
||||
.index_by { |record| record.public_send(index_key) }
|
||||
|
||||
@resolved_count = results.transform_values(&:resolved_count)
|
||||
@avg_resolution_time = results.transform_values(&:avg_resolution_time)
|
||||
@avg_first_response_time = results.transform_values(&:avg_first_response_time)
|
||||
@avg_reply_time = results.transform_values(&:avg_reply_time)
|
||||
end
|
||||
|
||||
def reporting_events
|
||||
@@ -24,14 +42,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def fetch_average_time(event_name)
|
||||
get_grouped_average(reporting_events.where(name: event_name))
|
||||
end
|
||||
|
||||
def fetch_resolved_count
|
||||
reporting_events.where(name: 'conversation_resolved').group(group_by_key).count
|
||||
end
|
||||
|
||||
def group_by_key
|
||||
# Override this method
|
||||
end
|
||||
@@ -40,10 +50,6 @@ class V2::Reports::BaseSummaryBuilder
|
||||
# Override this method
|
||||
end
|
||||
|
||||
def get_grouped_average(events)
|
||||
events.group(group_by_key).average(average_value_key)
|
||||
end
|
||||
|
||||
def average_value_key
|
||||
ActiveModel::Type::Boolean.new.cast(params[:business_hours]).present? ? :value_in_business_hours : :value
|
||||
end
|
||||
|
||||
@@ -13,10 +13,7 @@ class V2::Reports::InboxSummaryBuilder < V2::Reports::BaseSummaryBuilder
|
||||
|
||||
def load_data
|
||||
@conversations_count = fetch_conversations_count
|
||||
@resolved_count = fetch_resolved_count
|
||||
@avg_resolution_time = fetch_average_time('conversation_resolved')
|
||||
@avg_first_response_time = fetch_average_time('first_response')
|
||||
@avg_reply_time = fetch_average_time('reply_time')
|
||||
load_reporting_events_data
|
||||
end
|
||||
|
||||
def fetch_conversations_count
|
||||
|
||||
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
def edit; end
|
||||
|
||||
def create
|
||||
@article = @portal.articles.create!(article_params)
|
||||
params_with_defaults = article_params
|
||||
params_with_defaults[:status] ||= :draft
|
||||
@article = @portal.articles.create!(params_with_defaults)
|
||||
@article.associate_root_article(article_params[:associated_article_id])
|
||||
@article.draft!
|
||||
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
|
||||
end
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :type_matches?
|
||||
|
||||
def create
|
||||
if type_matches?
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: permitted_params
|
||||
)
|
||||
case normalized_type
|
||||
when 'Conversation'
|
||||
enqueue_conversation_job
|
||||
head :ok
|
||||
when 'Contact'
|
||||
check_authorization_for_contact_action
|
||||
enqueue_contact_job
|
||||
head :ok
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
@@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
|
||||
private
|
||||
|
||||
def type_matches?
|
||||
['Conversation'].include?(params[:type])
|
||||
def normalized_type
|
||||
params[:type].to_s.camelize
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
def enqueue_conversation_job
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: conversation_params
|
||||
)
|
||||
end
|
||||
|
||||
def enqueue_contact_job
|
||||
Contacts::BulkActionJob.perform_later(
|
||||
@current_account.id,
|
||||
current_user.id,
|
||||
contact_params
|
||||
)
|
||||
end
|
||||
|
||||
def delete_contact_action?
|
||||
params[:action_name] == 'delete'
|
||||
end
|
||||
|
||||
def check_authorization_for_contact_action
|
||||
authorize(Contact, :destroy?) if delete_contact_action?
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
||||
# and then remove this method in favor of a common params method.
|
||||
base = params.permit(
|
||||
:snoozed_until,
|
||||
fields: [:status, :assignee_id, :team_id]
|
||||
)
|
||||
append_common_bulk_attributes(base)
|
||||
end
|
||||
|
||||
def contact_params
|
||||
# TODO: remove this method in favor of a common params method.
|
||||
# once legacy conversation payloads are migrated.
|
||||
append_common_bulk_attributes({})
|
||||
end
|
||||
|
||||
def append_common_bulk_attributes(base_params)
|
||||
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
||||
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
||||
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
||||
base_params.merge(common)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def inbox
|
||||
|
||||
@@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC
|
||||
private
|
||||
|
||||
def authorize_request
|
||||
authorize @conversation.inbox, :show?
|
||||
authorize @conversation, :show?
|
||||
end
|
||||
|
||||
def render_response(response)
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||
params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
def fetch_webhook
|
||||
|
||||
@@ -14,6 +14,7 @@ module AccessTokenAuthHelper
|
||||
ensure_access_token
|
||||
render_unauthorized('Invalid Access Token') && return if @access_token.blank?
|
||||
|
||||
# NOTE: This ensures that current_user is set and available for the rest of the controller actions
|
||||
@resource = @access_token.owner
|
||||
Current.user = @resource if allowed_current_user_type?(@resource)
|
||||
end
|
||||
|
||||
@@ -19,6 +19,19 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa
|
||||
redirect_to login_page_url(email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token)
|
||||
end
|
||||
|
||||
def sign_in_user_on_mobile
|
||||
@resource.skip_confirmation! if confirmable_enabled?
|
||||
|
||||
# once the resource is found and verified
|
||||
# we can just send them to the login page again with the SSO params
|
||||
# that will log them in
|
||||
encoded_email = ERB::Util.url_encode(@resource.email)
|
||||
params = { email: encoded_email, sso_auth_token: @resource.generate_sso_auth_token }.to_query
|
||||
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?#{params}", allow_other_host: true
|
||||
end
|
||||
|
||||
def sign_up_user
|
||||
return redirect_to login_page_url(error: 'no-account-found') unless account_signup_allowed?
|
||||
return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain?
|
||||
|
||||
@@ -156,8 +156,7 @@ class ConversationFinder
|
||||
def filter_by_labels
|
||||
return unless params[:labels]
|
||||
|
||||
pattern = params[:labels].map { |label| Regexp.escape(label) }.join('|')
|
||||
@conversations = @conversations.where('cached_label_list ~ ?', "\\y(#{pattern})\\y")
|
||||
@conversations = @conversations.tagged_with(params[:labels], any: true)
|
||||
end
|
||||
|
||||
def filter_by_source_id
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
|
||||
# This module contains helper methods for converting between different data types,
|
||||
# normalizing hashes, and safely handling JSON operations.
|
||||
module DataHelper
|
||||
# Ensures a hash supports indifferent access (string or symbol keys).
|
||||
# Returns an empty hash if the input is blank.
|
||||
def ensure_indifferent_access(hash)
|
||||
return {} if hash.blank?
|
||||
|
||||
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
|
||||
end
|
||||
|
||||
def convert_to_hash(obj)
|
||||
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
|
||||
|
||||
obj
|
||||
end
|
||||
|
||||
def safe_parse_json(content)
|
||||
JSON.parse(content, symbolize_names: true)
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,19 @@ module EmailHelper
|
||||
domain.split('.').first
|
||||
end
|
||||
|
||||
def render_email_html(content)
|
||||
return '' if content.blank?
|
||||
|
||||
ChatwootMarkdownRenderer.new(content).render_message.to_s
|
||||
end
|
||||
|
||||
# Raise a standard error if any email address is invalid
|
||||
def validate_email_addresses(emails_to_test)
|
||||
emails_to_test&.each do |email|
|
||||
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
end
|
||||
end
|
||||
|
||||
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
|
||||
# This is not a mandatory requirement for email addresses, but it is a common practice.
|
||||
# john+test@xyc.com is the same as john@xyc.com
|
||||
@@ -21,6 +34,10 @@ module EmailHelper
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_email_body(content)
|
||||
content.to_s.gsub("\r\n", "\n")
|
||||
end
|
||||
|
||||
def modified_liquid_content(email)
|
||||
# This regex is used to match the code blocks in the content
|
||||
# We don't want to process liquid in code blocks
|
||||
@@ -29,7 +46,10 @@ module EmailHelper
|
||||
|
||||
def message_drops(conversation)
|
||||
{
|
||||
'contact' => ContactDrop.new(conversation.contact)
|
||||
'contact' => ContactDrop.new(conversation.contact),
|
||||
'conversation' => ConversationDrop.new(conversation),
|
||||
'inbox' => InboxDrop.new(conversation.inbox),
|
||||
'account' => AccountDrop.new(conversation.account)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from './components/app/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
@@ -25,7 +24,6 @@ export default {
|
||||
name: 'App',
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
@@ -51,7 +49,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
};
|
||||
@@ -64,21 +61,12 @@ export default {
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
}),
|
||||
hasAccounts() {
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
return accounts.length > 0;
|
||||
},
|
||||
hideOnOnboardingView() {
|
||||
return !isOnOnboardingView(this.$route);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentUser() {
|
||||
if (!this.hasAccounts) {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
},
|
||||
currentAccountId: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
@@ -156,7 +144,6 @@ export default {
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
|
||||
<WootSnackbarBox />
|
||||
<NetworkNotification />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import ApiClient from './ApiClient';
|
||||
import { CHANGELOG_API_URL } from 'shared/constants/links';
|
||||
|
||||
class ChangelogApi extends ApiClient {
|
||||
constructor() {
|
||||
super('changelog', { apiVersion: 'v1' });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchFromHub() {
|
||||
return axios.get(CHANGELOG_API_URL);
|
||||
}
|
||||
}
|
||||
|
||||
export default new ChangelogApi();
|
||||
+38
-35
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
defineProps({
|
||||
selectedContact: {
|
||||
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="hover:!no-underline text-n-slate-12"
|
||||
icon="i-lucide-chevron-down"
|
||||
trailing-icon
|
||||
@click="toggleDeleteSection()"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
<div
|
||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||
:class="
|
||||
showDeleteSection
|
||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden min-h-0">
|
||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
@click="openConfirmDeleteContactDialog()"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
</Policy>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -20,9 +21,17 @@ const props = defineProps({
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'updateContact',
|
||||
'showContact',
|
||||
'select',
|
||||
'avatarHover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -88,111 +97,148 @@ const onClickExpand = () => {
|
||||
};
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
|
||||
const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
|
||||
const handleAvatarHover = isHovered => {
|
||||
emit('avatarHover', isHovered);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
<div class="relative">
|
||||
<CardLayout
|
||||
:key="id"
|
||||
layout="row"
|
||||
:class="{
|
||||
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||
>
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
|
||||
defineProps({ contacts: { type: Array, required: true } });
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
selectedContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleContact']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -20,6 +28,9 @@ const route = useRoute();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
const hoveredAvatarId = ref(null);
|
||||
|
||||
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
try {
|
||||
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
|
||||
const shouldShowSelection = id => {
|
||||
return hoveredAvatarId.value === id || isSelected(id);
|
||||
};
|
||||
|
||||
const handleSelect = (id, value) => {
|
||||
emit('toggleContact', { id, value });
|
||||
};
|
||||
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||
<ContactsCard
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
:selectable="shouldShowSelection(contact.id)"
|
||||
:is-selected="isSelected(contact.id)"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
@select="value => handleSelect(contact.id, value)"
|
||||
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { computed } from 'vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
||||
<ButtonGroup
|
||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full"
|
||||
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
||||
:class="{
|
||||
'bg-n-alpha-2': isContactSidebarOpen,
|
||||
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
||||
}"
|
||||
icon="i-ph-user-bold"
|
||||
@click="handleConversationSidebarToggle"
|
||||
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
|
||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||
ghost
|
||||
slate
|
||||
class="!rounded-full"
|
||||
:class="{
|
||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
||||
}"
|
||||
sm
|
||||
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
|
||||
:class="{
|
||||
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
|
||||
isCopilotPanelOpen,
|
||||
}"
|
||||
icon="i-woot-captain"
|
||||
@click="handleCopilotSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,10 @@ const props = defineProps({
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
enableCaptainTools: { type: Boolean, default: false },
|
||||
signature: { type: String, default: '' },
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
@@ -100,6 +104,10 @@ watch(
|
||||
:enable-canned-responses="enableCannedResponses"
|
||||
:enabled-menu-options="enabledMenuOptions"
|
||||
:enable-captain-tools="enableCaptainTools"
|
||||
:signature="signature"
|
||||
:allow-signature="allowSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
@input="handleInput"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showBackdrop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,14 +29,24 @@ defineProps({
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||
>
|
||||
<div
|
||||
v-if="showBackdrop"
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
||||
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||
:class="{
|
||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
||||
showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-6"
|
||||
:class="{
|
||||
'mt-48': !showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||
@@ -40,6 +54,7 @@ defineProps({
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
|
||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
||||
} from 'dashboard/helper/portalHelper';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
|
||||
:disabled="!articleId"
|
||||
@click="previewArticle"
|
||||
/>
|
||||
<div class="flex items-center">
|
||||
<ButtonGroup class="flex items-center">
|
||||
<Button
|
||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||
size="sm"
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||
no-animation
|
||||
:is-loading="isArticlePublishing"
|
||||
:disabled="
|
||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
:disabled="!articleId"
|
||||
no-animation
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||
/>
|
||||
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +92,6 @@ const setSignature = () => {
|
||||
|
||||
const toggleMessageSignature = () => {
|
||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||
setSignature();
|
||||
};
|
||||
|
||||
// Added this watch to dynamically set signature on target inbox change.
|
||||
|
||||
+14
-8
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
// Remove the signature from message content
|
||||
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
||||
if (props.sendWithSignature) {
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
const removeSignatureFromMessage = () => {
|
||||
// Always remove the signature from message content when inbox/contact is removed
|
||||
// to ensure no leftover signature content remains
|
||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||
? props.messageSignature
|
||||
: extractTextFromMarkdown(props.messageSignature);
|
||||
if (signatureToRemove) {
|
||||
state.message = removeSignature(state.message, signatureToRemove);
|
||||
}
|
||||
};
|
||||
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
|
||||
const clearSelectedContact = () => {
|
||||
emit('clearSelectedContact');
|
||||
state.attachedFiles = [];
|
||||
removeSignatureFromMessage();
|
||||
};
|
||||
|
||||
const onClickInsertEmoji = emoji => {
|
||||
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
|
||||
@@ -17,6 +17,7 @@ const props = defineProps({
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -90,6 +91,10 @@ const replaceText = async message => {
|
||||
"
|
||||
enable-variables
|
||||
:show-character-count="false"
|
||||
:signature="messageSignature"
|
||||
allow-signature
|
||||
:send-with-signature="sendWithSignature"
|
||||
:channel-type="channelType"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
|
||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
trailingIcon: { type: Boolean, default: false },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
noAnimation: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
|
||||
md: 'text-sm font-medium',
|
||||
lg: 'text-base',
|
||||
},
|
||||
clickAnimation: {
|
||||
xs: 'active:enabled:scale-[0.97]',
|
||||
sm: 'active:enabled:scale-[0.97]',
|
||||
md: 'active:enabled:scale-[0.98]',
|
||||
lg: 'active:enabled:scale-[0.98]',
|
||||
},
|
||||
justify: {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
},
|
||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||
};
|
||||
|
||||
const variantClasses = computed(() => {
|
||||
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
|
||||
|
||||
return classes.join(' ');
|
||||
});
|
||||
|
||||
const animationClasses = computed(() => {
|
||||
return props.noAnimation
|
||||
? ''
|
||||
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
|
||||
[STYLE_CONFIG.base]: true,
|
||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||
[animationClasses]: true,
|
||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||
}"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
noAnimation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
noAnimation
|
||||
? ''
|
||||
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import BackButton from 'dashboard/components/widgets/BackButton.vue';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import AssistantSwitcher from 'dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue';
|
||||
import CreateAssistantDialog from 'dashboard/components-next/captain/pageComponents/assistant/CreateAssistantDialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
@@ -56,11 +61,32 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showAssistantSwitcher: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'close', 'update:currentPage']);
|
||||
|
||||
const route = useRoute();
|
||||
const { shouldShowPaywall } = usePolicy();
|
||||
|
||||
const showAssistantSwitcherDropdown = ref(false);
|
||||
const createAssistantDialogRef = ref(null);
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const currentAssistantId = computed(() => route.params.assistantId);
|
||||
|
||||
const activeAssistantName = computed(() => {
|
||||
return (
|
||||
assistants.value?.find(
|
||||
assistant => assistant.id === Number(currentAssistantId.value)
|
||||
)?.name || ''
|
||||
);
|
||||
});
|
||||
|
||||
const showPaywall = computed(() => {
|
||||
return shouldShowPaywall(props.featureFlag);
|
||||
});
|
||||
@@ -72,6 +98,15 @@ const handleButtonClick = () => {
|
||||
const handlePageChange = event => {
|
||||
emit('update:currentPage', event);
|
||||
};
|
||||
|
||||
const toggleAssistantSwitcher = () => {
|
||||
showAssistantSwitcherDropdown.value = !showAssistantSwitcherDropdown.value;
|
||||
};
|
||||
|
||||
const handleCreateAssistant = () => {
|
||||
showAssistantSwitcherDropdown.value = false;
|
||||
createAssistantDialogRef.value.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -81,39 +116,81 @@ const handlePageChange = event => {
|
||||
<div
|
||||
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
|
||||
>
|
||||
<div class="flex gap-4 items-center">
|
||||
<BackButton v-if="backUrl" :to="backUrl" />
|
||||
<slot name="headerTitle">
|
||||
<span class="text-xl font-medium text-n-slate-12">
|
||||
<div class="flex gap-3 items-center">
|
||||
<BackButton v-if="backUrl" :back-url="backUrl" />
|
||||
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="activeAssistantName"
|
||||
class="text-xl font-medium truncate text-n-slate-12"
|
||||
>
|
||||
{{ activeAssistantName }}
|
||||
</span>
|
||||
<div v-if="activeAssistantName" class="relative group">
|
||||
<OnClickOutside
|
||||
@trigger="showAssistantSwitcherDropdown = false"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
|
||||
@click="toggleAssistantSwitcher"
|
||||
/>
|
||||
|
||||
<AssistantSwitcher
|
||||
v-if="showAssistantSwitcherDropdown"
|
||||
class="absolute ltr:left-0 rtl:right-0 top-9"
|
||||
@close="showAssistantSwitcherDropdown = false"
|
||||
@create-assistant="handleCreateAssistant"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
v-if="showAssistantSwitcher && headerTitle"
|
||||
class="w-0.5 h-4 rounded-2xl bg-n-weak"
|
||||
/>
|
||||
<span
|
||||
v-if="headerTitle"
|
||||
class="text-xl font-medium text-n-slate-12"
|
||||
>
|
||||
{{ headerTitle }}
|
||||
</span>
|
||||
</slot>
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
<div
|
||||
v-if="!isEmpty && showKnowMore"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
|
||||
<slot name="knowMore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/campaign-button"
|
||||
>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/campaign-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
<div class="flex gap-2">
|
||||
<slot name="search" />
|
||||
<div
|
||||
v-if="!showPaywall && buttonLabel"
|
||||
v-on-clickaway="() => emit('close')"
|
||||
class="relative group/captain-button"
|
||||
>
|
||||
<Policy :permissions="buttonPolicy">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="group-hover/captain-button:brightness-110"
|
||||
@click="handleButtonClick"
|
||||
/>
|
||||
</Policy>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="subHeader" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
@@ -143,5 +220,6 @@ const handlePageChange = event => {
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
<CreateAssistantDialog ref="createAssistantDialogRef" type="create" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
|
||||
defineProps({
|
||||
isFetching: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEmpty: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
totalCount: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
itemsPerPage: {
|
||||
type: Number,
|
||||
default: 25,
|
||||
},
|
||||
showPaginationFooter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
breadcrumbItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handlePageChange = event => {
|
||||
emit('update:currentPage', event);
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = item => {
|
||||
router.push({
|
||||
name: item.routeName,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
>
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||
<header class="mb-7 sticky top-0 bg-n-background pt-4 z-20">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</header>
|
||||
<main class="flex gap-16 w-full flex-1 pb-16">
|
||||
<section
|
||||
v-if="$slots.body || $slots.emptyState || isFetching"
|
||||
class="flex flex-col w-full"
|
||||
>
|
||||
<div
|
||||
v-if="isFetching"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="isEmpty">
|
||||
<slot name="emptyState" />
|
||||
</div>
|
||||
<slot v-else name="body" />
|
||||
</section>
|
||||
<section v-if="$slots.controls" class="flex w-full">
|
||||
<slot name="controls" />
|
||||
</section>
|
||||
</main>
|
||||
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10 pb-4">
|
||||
<PaginationFooter
|
||||
:current-page="currentPage"
|
||||
:total-items="totalCount"
|
||||
:items-per-page="itemsPerPage"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -76,12 +76,11 @@ const handleAction = ({ action, value }) => {
|
||||
<template>
|
||||
<CardLayout>
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<router-link
|
||||
:to="{ name: 'captain_assistants_edit', params: { assistantId: id } }"
|
||||
class="text-base text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
<h6
|
||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||
>
|
||||
{{ name }}
|
||||
</router-link>
|
||||
</h6>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import MessageList from './MessageList.vue';
|
||||
@@ -29,6 +29,16 @@ const resetConversation = () => {
|
||||
newMessage.value = '';
|
||||
};
|
||||
|
||||
// Watch for assistant ID changes and reset conversation
|
||||
watch(
|
||||
() => assistantId,
|
||||
(newId, oldId) => {
|
||||
if (oldId && newId !== oldId) {
|
||||
resetConversation();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || isLoading.value) return;
|
||||
|
||||
@@ -65,16 +75,17 @@ const sendMessage = async () => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
|
||||
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<div class="mb-8 px-6">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<h3 class="text-lg font-medium">
|
||||
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
|
||||
</h3>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
sm
|
||||
slate
|
||||
icon="i-lucide-rotate-ccw"
|
||||
@click="resetConversation"
|
||||
/>
|
||||
@@ -87,17 +98,17 @@ const sendMessage = async () => {
|
||||
<MessageList :messages="messages" :is-loading="isLoading" />
|
||||
|
||||
<div
|
||||
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
|
||||
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
|
||||
>
|
||||
<input
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
size="small"
|
||||
sm
|
||||
:disabled="!newMessage.trim()"
|
||||
icon="i-lucide-send"
|
||||
@click="sendMessage"
|
||||
|
||||
@@ -64,20 +64,23 @@ const bulkCheckboxState = computed({
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||
<span
|
||||
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
|
||||
>
|
||||
{{ selectAllLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
|
||||
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
|
||||
|
||||
const getMessageStyle = sender =>
|
||||
isUserMessage(sender)
|
||||
? 'bg-n-strong text-n-white'
|
||||
: 'bg-n-solid-iris text-n-slate-12';
|
||||
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
|
||||
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
|
||||
<div
|
||||
ref="messageContainer"
|
||||
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
|
||||
>
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
|
||||
:class="getMessageAlignment(message.sender)"
|
||||
>
|
||||
<div
|
||||
class="flex items-start gap-1.5"
|
||||
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
|
||||
:class="getMessageDirection(message.sender)"
|
||||
>
|
||||
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
|
||||
<Avatar
|
||||
:name="getAvatarName(message.sender)"
|
||||
rounded-full
|
||||
:size="24"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div
|
||||
class="max-w-[80%] rounded-lg p-3 text-sm"
|
||||
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
|
||||
:class="getMessageStyle(message.sender)"
|
||||
>
|
||||
<div class="break-words" v-html="formatMessage(message.content)" />
|
||||
<div v-html="formatMessage(message.content)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<span
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
class="max-w-96 truncate"
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<i class="i-ph-calendar-dot" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistantId: {
|
||||
type: [String, Number],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update']);
|
||||
const { t } = useI18n();
|
||||
const isFilterOpen = ref(false);
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const assistantOptions = computed(() => [
|
||||
{
|
||||
label: t(`CAPTAIN.RESPONSES.FILTER.ALL_ASSISTANTS`),
|
||||
value: 'all',
|
||||
action: 'filter',
|
||||
},
|
||||
...assistants.value.map(assistant => ({
|
||||
value: assistant.id,
|
||||
label: assistant.name,
|
||||
action: 'filter',
|
||||
})),
|
||||
]);
|
||||
|
||||
const selectedAssistantLabel = computed(() => {
|
||||
const assistant = assistantOptions.value.find(
|
||||
option => option.value === props.assistantId
|
||||
);
|
||||
return t('CAPTAIN.RESPONSES.FILTER.ASSISTANT', {
|
||||
selected: assistant ? assistant.label : '',
|
||||
});
|
||||
});
|
||||
|
||||
const handleAssistantFilterChange = ({ value }) => {
|
||||
isFilterOpen.value = false;
|
||||
emit('update', value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OnClickOutside @trigger="isFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedAssistantLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isFilterOpen = !isFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isFilterOpen"
|
||||
:menu-items="assistantOptions"
|
||||
class="mt-2"
|
||||
@action="handleAssistantFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
</template>
|
||||
+13
-3
@@ -18,7 +18,7 @@ const props = defineProps({
|
||||
validator: value => ['create', 'edit'].includes(value),
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const emit = defineEmits(['close', 'created']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
@@ -35,8 +35,18 @@ const i18nKey = computed(
|
||||
() => `CAPTAIN.ASSISTANTS.${props.type.toUpperCase()}`
|
||||
);
|
||||
|
||||
const createAssistant = assistantDetails =>
|
||||
store.dispatch('captainAssistants/create', assistantDetails);
|
||||
const createAssistant = async assistantDetails => {
|
||||
try {
|
||||
const newAssistant = await store.dispatch(
|
||||
'captainAssistants/create',
|
||||
assistantDetails
|
||||
);
|
||||
emit('created', newAssistant);
|
||||
} catch (error) {
|
||||
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async updatedAssistant => {
|
||||
try {
|
||||
|
||||
-333
@@ -1,333 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['edit', 'create'].includes(value),
|
||||
},
|
||||
assistant: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainAssistants/getUIFlags'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
description: '',
|
||||
productName: '',
|
||||
welcomeMessage: '',
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
features: {
|
||||
conversationFaqs: false,
|
||||
memories: false,
|
||||
citations: false,
|
||||
},
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
|
||||
const validationRules = {
|
||||
name: { required, minLength: minLength(1) },
|
||||
description: { required, minLength: minLength(1) },
|
||||
productName: { required, minLength: minLength(1) },
|
||||
welcomeMessage: { minLength: minLength(1) },
|
||||
handoffMessage: { minLength: minLength(1) },
|
||||
resolutionMessage: { minLength: minLength(1) },
|
||||
instructions: { minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
|
||||
const getErrorMessage = field => {
|
||||
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
name: getErrorMessage('name'),
|
||||
description: getErrorMessage('description'),
|
||||
productName: getErrorMessage('productName'),
|
||||
welcomeMessage: getErrorMessage('welcomeMessage'),
|
||||
handoffMessage: getErrorMessage('handoffMessage'),
|
||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||
instructions: getErrorMessage('instructions'),
|
||||
}));
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
const { config = {} } = assistant;
|
||||
state.name = assistant.name;
|
||||
state.description = assistant.description;
|
||||
state.productName = config.product_name;
|
||||
state.welcomeMessage = config.welcome_message;
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.features = {
|
||||
conversationFaqs: config.feature_faq || false,
|
||||
memories: config.feature_memory || false,
|
||||
citations: config.feature_citation || false,
|
||||
};
|
||||
state.temperature = config.temperature || 1;
|
||||
};
|
||||
|
||||
const handleBasicInfoUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
v$.value.name.$validate(),
|
||||
v$.value.description.$validate(),
|
||||
v$.value.productName.$validate(),
|
||||
]).then(results => results.every(Boolean));
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
name: state.name,
|
||||
description: state.description,
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
product_name: state.productName,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
v$.value.welcomeMessage.$validate(),
|
||||
v$.value.handoffMessage.$validate(),
|
||||
v$.value.resolutionMessage.$validate(),
|
||||
]).then(results => results.every(Boolean));
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
welcome_message: state.welcomeMessage,
|
||||
handoff_message: state.handoffMessage,
|
||||
resolution_message: state.resolutionMessage,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleInstructionsUpdate = async () => {
|
||||
const result = await v$.value.instructions.$validate();
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
temperature: state.temperature || 1,
|
||||
instructions: state.instructions,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
const handleFeaturesUpdate = () => {
|
||||
const payload = {
|
||||
config: {
|
||||
...props.assistant.config,
|
||||
feature_faq: state.features.conversationFaqs,
|
||||
feature_memory: state.features.memories,
|
||||
feature_citation: state.features.citations,
|
||||
},
|
||||
};
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.assistant,
|
||||
newAssistant => {
|
||||
if (props.mode === 'edit' && newAssistant) {
|
||||
updateStateFromAssistant(newAssistant);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<!-- Basic Information Section -->
|
||||
<Accordion
|
||||
:title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.BASIC_INFO')"
|
||||
is-open
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<Input
|
||||
v-model="state.name"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.NAME.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.NAME.PLACEHOLDER')"
|
||||
:message="formErrors.name"
|
||||
:message-type="formErrors.name ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.description"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
:message-type="formErrors.description ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.productName"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.PRODUCT_NAME.PLACEHOLDER')"
|
||||
:message="formErrors.productName"
|
||||
:message-type="formErrors.productName ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
@click="handleBasicInfoUpdate"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.UPDATE') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Instructions Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.INSTRUCTIONS')">
|
||||
<div class="flex flex-col gap-4">
|
||||
<Editor
|
||||
v-model="state.instructions"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||
:message="formErrors.instructions"
|
||||
:max-length="20000"
|
||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
<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 class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleInstructionsUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Greeting Messages Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.SYSTEM_MESSAGES')">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<Editor
|
||||
v-model="state.handoffMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.resolutionMessage"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.LABEL')"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.resolutionMessage"
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleSystemMessagesUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<!-- Features Section -->
|
||||
<Accordion :title="t('CAPTAIN.ASSISTANTS.FORM.SECTIONS.FEATURES')">
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="state.features.conversationFaqs"
|
||||
type="checkbox"
|
||||
/>
|
||||
{{
|
||||
t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CONVERSATION_FAQS')
|
||||
}}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.memories" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_MEMORIES') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="state.features.citations" type="checkbox" />
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.ALLOW_CITATIONS') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
size="small"
|
||||
:loading="isLoading"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.UPDATE')"
|
||||
@click="handleFeaturesUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
</form>
|
||||
</template>
|
||||
+35
-2
@@ -3,6 +3,8 @@ import { reactive, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { minLength } from '@vuelidate/validators';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
@@ -17,10 +19,16 @@ const props = defineProps({
|
||||
const emit = defineEmits(['submit']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
|
||||
const isCaptainV2Enabled = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
handoffMessage: '',
|
||||
resolutionMessage: '',
|
||||
instructions: '',
|
||||
temperature: 1,
|
||||
};
|
||||
|
||||
@@ -29,6 +37,7 @@ const state = reactive({ ...initialState });
|
||||
const validationRules = {
|
||||
handoffMessage: { minLength: minLength(1) },
|
||||
resolutionMessage: { minLength: minLength(1) },
|
||||
instructions: { minLength: minLength(1) },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
@@ -40,20 +49,30 @@ const getErrorMessage = field => {
|
||||
const formErrors = computed(() => ({
|
||||
handoffMessage: getErrorMessage('handoffMessage'),
|
||||
resolutionMessage: getErrorMessage('resolutionMessage'),
|
||||
instructions: getErrorMessage('instructions'),
|
||||
}));
|
||||
|
||||
const updateStateFromAssistant = assistant => {
|
||||
const { config = {} } = assistant;
|
||||
state.handoffMessage = config.handoff_message;
|
||||
state.resolutionMessage = config.resolution_message;
|
||||
state.instructions = config.instructions;
|
||||
state.temperature = config.temperature || 1;
|
||||
};
|
||||
|
||||
const handleSystemMessagesUpdate = async () => {
|
||||
const result = await Promise.all([
|
||||
const validations = [
|
||||
v$.value.handoffMessage.$validate(),
|
||||
v$.value.resolutionMessage.$validate(),
|
||||
]).then(results => results.every(Boolean));
|
||||
];
|
||||
|
||||
if (!isCaptainV2Enabled.value) {
|
||||
validations.push(v$.value.instructions.$validate());
|
||||
}
|
||||
|
||||
const result = await Promise.all(validations).then(results =>
|
||||
results.every(Boolean)
|
||||
);
|
||||
if (!result) return;
|
||||
|
||||
const payload = {
|
||||
@@ -65,6 +84,10 @@ const handleSystemMessagesUpdate = async () => {
|
||||
},
|
||||
};
|
||||
|
||||
if (!isCaptainV2Enabled.value) {
|
||||
payload.config.instructions = state.instructions;
|
||||
}
|
||||
|
||||
emit('submit', payload);
|
||||
};
|
||||
|
||||
@@ -95,6 +118,16 @@ watch(
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-if="!isCaptainV2Enabled"
|
||||
v-model="state.instructions"
|
||||
:label="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.LABEL')"
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.INSTRUCTIONS.PLACEHOLDER')"
|
||||
:message="formErrors.instructions"
|
||||
:max-length="20000"
|
||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.ASSISTANTS.FORM.TEMPERATURE.LABEL') }}
|
||||
|
||||
+12
-1
@@ -8,6 +8,13 @@ import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import DocumentForm from './DocumentForm.vue';
|
||||
|
||||
defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -48,7 +55,11 @@ defineExpose({ dialogRef });
|
||||
:show-confirm-button="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<DocumentForm @submit="handleSubmit" @cancel="handleCancel" />
|
||||
<DocumentForm
|
||||
:assistant-id="assistantId"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
<template #footer />
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
+9
-28
@@ -2,7 +2,7 @@
|
||||
import { reactive, computed, ref, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength, requiredIf, url } from '@vuelidate/validators';
|
||||
import { minLength, requiredIf, url } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
@@ -10,6 +10,13 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -18,13 +25,11 @@ const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainDocuments/getUIFlags'),
|
||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
name: '',
|
||||
url: '',
|
||||
assistantId: null,
|
||||
documentType: 'url',
|
||||
pdfFile: null,
|
||||
};
|
||||
@@ -38,19 +43,11 @@ const validationRules = {
|
||||
url: requiredIf(() => state.documentType === 'url' && url),
|
||||
minLength: requiredIf(() => state.documentType === 'url' && minLength(1)),
|
||||
},
|
||||
assistantId: { required },
|
||||
pdfFile: {
|
||||
required: requiredIf(() => state.documentType === 'pdf'),
|
||||
},
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
value: assistant.id,
|
||||
label: assistant.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const documentTypeOptions = [
|
||||
{ value: 'url', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.URL') },
|
||||
{ value: 'pdf', label: t('CAPTAIN.DOCUMENTS.FORM.TYPE.PDF') },
|
||||
@@ -70,7 +67,6 @@ const getErrorMessage = (field, errorKey) => {
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
url: getErrorMessage('url', 'URL'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
pdfFile: getErrorMessage('pdfFile', 'PDF_FILE'),
|
||||
}));
|
||||
|
||||
@@ -106,7 +102,7 @@ const openFileDialog = () => {
|
||||
|
||||
const prepareDocumentDetails = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('document[assistant_id]', state.assistantId);
|
||||
formData.append('document[assistant_id]', props.assistantId);
|
||||
|
||||
if (state.documentType === 'url') {
|
||||
formData.append('document[external_link]', state.url);
|
||||
@@ -218,21 +214,6 @@ const handleSubmit = async () => {
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.NAME.PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="assistant"
|
||||
v-model="state.assistantId"
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
:placeholder="t('CAPTAIN.DOCUMENTS.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
:message="formErrors.assistantId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -20,6 +22,7 @@ const onClick = () => {
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-assistant"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
|
||||
|
||||
+3
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
|
||||
@@ -6,6 +7,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
|
||||
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -19,6 +21,7 @@ const onClick = () => {
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/document-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/document-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-document"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
|
||||
+50
-9
@@ -1,32 +1,63 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'approved',
|
||||
validator: value => ['approved', 'pending'].includes(value),
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'clearFilters']);
|
||||
|
||||
const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onClearFilters = () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
v-if="isApproved"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ResponseCard
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
@@ -42,11 +73,21 @@ const onClick = () => {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
v-if="isApproved"
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="isPending && hasActiveFilters"
|
||||
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="onClearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
+10
-2
@@ -3,6 +3,7 @@ import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ResponseForm from './ResponseForm.vue';
|
||||
@@ -21,6 +22,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const responseForm = ref(null);
|
||||
@@ -39,9 +41,15 @@ const createResponse = responseDetails =>
|
||||
const handleSubmit = async updatedResponse => {
|
||||
try {
|
||||
if (props.type === 'edit') {
|
||||
await updateResponse(updatedResponse);
|
||||
await updateResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
} else {
|
||||
await createResponse(updatedResponse);
|
||||
await createResponse({
|
||||
...updatedResponse,
|
||||
assistant_id: route.params.assistantId,
|
||||
});
|
||||
}
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
|
||||
+2
-32
@@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -21,18 +20,17 @@ const props = defineProps({
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('captainResponses/getUIFlags'),
|
||||
assistants: useMapGetter('captainAssistants/getRecords'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
question: '',
|
||||
answer: '',
|
||||
assistantId: null,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
@@ -40,16 +38,8 @@ const state = reactive({ ...initialState });
|
||||
const validationRules = {
|
||||
question: { required, minLength: minLength(1) },
|
||||
answer: { required, minLength: minLength(1) },
|
||||
assistantId: { required },
|
||||
};
|
||||
|
||||
const assistantList = computed(() =>
|
||||
formState.assistants.value.map(assistant => ({
|
||||
value: assistant.id,
|
||||
label: assistant.name,
|
||||
}))
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
@@ -63,7 +53,6 @@ const getErrorMessage = (field, errorKey) => {
|
||||
const formErrors = computed(() => ({
|
||||
question: getErrorMessage('question', 'QUESTION'),
|
||||
answer: getErrorMessage('answer', 'ANSWER'),
|
||||
assistantId: getErrorMessage('assistantId', 'ASSISTANT'),
|
||||
}));
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
@@ -71,7 +60,6 @@ const handleCancel = () => emit('cancel');
|
||||
const prepareDocumentDetails = () => ({
|
||||
question: state.question,
|
||||
answer: state.answer,
|
||||
assistant_id: state.assistantId,
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -86,12 +74,11 @@ const handleSubmit = async () => {
|
||||
const updateStateFromResponse = response => {
|
||||
if (!response) return;
|
||||
|
||||
const { question, answer, assistant } = response;
|
||||
const { question, answer } = response;
|
||||
|
||||
Object.assign(state, {
|
||||
question,
|
||||
answer,
|
||||
assistantId: assistant.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,7 +102,6 @@ watch(
|
||||
:message="formErrors.question"
|
||||
:message-type="formErrors.question ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
v-model="state.answer"
|
||||
:label="t('CAPTAIN.RESPONSES.FORM.ANSWER.LABEL')"
|
||||
@@ -124,22 +110,6 @@ watch(
|
||||
:max-length="10000"
|
||||
:message-type="formErrors.answer ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="assistant" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN.RESPONSES.FORM.ASSISTANT.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="assistant"
|
||||
v-model="state.assistantId"
|
||||
:options="assistantList"
|
||||
:has-error="!!formErrors.assistantId"
|
||||
:placeholder="t('CAPTAIN.RESPONSES.FORM.ASSISTANT.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.assistantId"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'createAssistant']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const currentAssistantId = computed(() => route.params.assistantId);
|
||||
|
||||
const isAssistantActive = assistant => {
|
||||
return assistant.id === Number(currentAssistantId.value);
|
||||
};
|
||||
|
||||
const fetchDataForRoute = async (routeName, assistantId) => {
|
||||
const dataFetchMap = {
|
||||
captain_assistants_responses_index: async () => {
|
||||
await store.dispatch('captainResponses/get', { assistantId });
|
||||
await store.dispatch('captainResponses/fetchPendingCount', assistantId);
|
||||
},
|
||||
captain_assistants_responses_pending: async () => {
|
||||
await store.dispatch('captainResponses/get', {
|
||||
assistantId,
|
||||
status: 'pending',
|
||||
});
|
||||
},
|
||||
captain_assistants_documents_index: async () => {
|
||||
await store.dispatch('captainDocuments/get', { assistantId });
|
||||
},
|
||||
captain_assistants_scenarios_index: async () => {
|
||||
await store.dispatch('captainScenarios/get', { assistantId });
|
||||
},
|
||||
captain_assistants_playground_index: () => {
|
||||
// Playground doesn't need pre-fetching, it loads on interaction
|
||||
},
|
||||
captain_assistants_inboxes_index: async () => {
|
||||
await store.dispatch('captainInboxes/get', { assistantId });
|
||||
},
|
||||
captain_tools_index: async () => {
|
||||
await store.dispatch('captainCustomTools/get', { page: 1 });
|
||||
},
|
||||
captain_assistants_settings_index: async () => {
|
||||
await store.dispatch('captainAssistants/show', assistantId);
|
||||
},
|
||||
};
|
||||
|
||||
const fetchFn = dataFetchMap[routeName];
|
||||
if (fetchFn) {
|
||||
await fetchFn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssistantChange = async assistant => {
|
||||
if (isAssistantActive(assistant)) return;
|
||||
|
||||
const currentRouteName = route.name;
|
||||
const targetRouteName =
|
||||
currentRouteName || 'captain_assistants_responses_index';
|
||||
|
||||
await fetchDataForRoute(targetRouteName, assistant.id);
|
||||
|
||||
await router.push({
|
||||
name: targetRouteName,
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: assistant.id,
|
||||
},
|
||||
});
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const openCreateAssistantDialog = () => {
|
||||
emit('createAssistant');
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pt-5 pb-3 bg-n-alpha-3 backdrop-blur-[100px] outline outline-n-container outline-1 z-50 absolute w-[27.5rem] rounded-xl shadow-md flex flex-col gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 px-6 pb-3 border-b border-n-alpha-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2
|
||||
class="text-base font-medium cursor-pointer text-n-slate-12 w-fit hover:underline"
|
||||
>
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ASSISTANTS') }}
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
|
||||
color="slate"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
|
||||
@click="openCreateAssistantDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
|
||||
<Button
|
||||
v-for="assistant in assistants"
|
||||
:key="assistant.id"
|
||||
:label="assistant.name"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
:icon="isAssistantActive(assistant) ? 'i-lucide-check' : ''"
|
||||
class="!justify-end !px-2 !py-2 hover:!bg-n-alpha-2 [&>.i-lucide-check]:text-n-teal-10 h-9"
|
||||
size="sm"
|
||||
@click="handleAssistantChange(assistant)"
|
||||
>
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ assistant.name || '' }}
|
||||
</span>
|
||||
<Avatar
|
||||
v-if="assistant"
|
||||
:name="assistant.name"
|
||||
:size="20"
|
||||
icon-name="i-lucide-bot"
|
||||
rounded-full
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import GroupedStackedChangelogCard from './GroupedStackedChangelogCard.vue';
|
||||
|
||||
const sampleCards = [
|
||||
{
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
},
|
||||
{
|
||||
id: 'smart-routing',
|
||||
title: 'Smart Routing Forms',
|
||||
meta_title: 'Smart Routing Forms',
|
||||
meta_description:
|
||||
'Screen bookers with intelligent forms and route them to the right team member.',
|
||||
slug: 'smart-routing',
|
||||
feature_image: 'https://www.chatwoot.com/images/dashboard-dark.webp',
|
||||
},
|
||||
{
|
||||
id: 'instant-meetings',
|
||||
title: 'Instant Meetings',
|
||||
meta_title: 'Instant Meetings',
|
||||
meta_description: 'Start instant meetings directly from shared links.',
|
||||
slug: 'instant-meetings',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1587614382346-4ec70e388b28?w=600',
|
||||
},
|
||||
{
|
||||
id: 'analytics',
|
||||
title: 'Advanced Analytics',
|
||||
meta_title: 'Advanced Analytics',
|
||||
meta_description:
|
||||
'Track meeting performance, conversion, and response rates in one place.',
|
||||
slug: 'analytics',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=500',
|
||||
},
|
||||
{
|
||||
id: 'team-collaboration',
|
||||
title: 'Team Collaboration',
|
||||
meta_title: 'Team Collaboration',
|
||||
meta_description:
|
||||
'Coordinate with your team seamlessly using shared availability.',
|
||||
slug: 'team-collaboration',
|
||||
feature_image:
|
||||
'https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400',
|
||||
},
|
||||
];
|
||||
|
||||
const visibleCards = ref([...sampleCards]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
|
||||
const handleReadMore = slug => {
|
||||
console.log(`Read more: ${slug}`);
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
const idx = visibleCards.value.findIndex(c => c.slug === slug);
|
||||
if (idx !== -1) visibleCards.value.splice(idx, 1);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= visibleCards.value.length) currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleImgClick = data => {
|
||||
currentIndex.value = data.index;
|
||||
console.log(`Card clicked: ${visibleCards.value[data.index].title}`);
|
||||
};
|
||||
|
||||
const resetDemo = () => {
|
||||
visibleCards.value = [...sampleCards];
|
||||
currentIndex.value = 0;
|
||||
dismissingCards.value = [];
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/GroupedStackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '320px' }"
|
||||
>
|
||||
<Variant title="Interactive Demo">
|
||||
<div class="p-4 bg-n-solid-2 rounded-md mx-auto w-64 h-[400px]">
|
||||
<GroupedStackedChangelogCard
|
||||
:posts="visibleCards"
|
||||
:current-index="currentIndex"
|
||||
:is-active="currentIndex === 0"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[270px]"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="mt-3 px-3 py-1 text-xs font-medium bg-n-brand text-white rounded hover:bg-n-brand/80 transition"
|
||||
@click="resetDemo"
|
||||
>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
{{ 'Reset Cards' }}
|
||||
</button>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
posts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
currentIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
dismissingSlugs: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||
|
||||
const stackedPosts = computed(() => props.posts?.slice(0, 5));
|
||||
|
||||
const isPostDismissing = post => props.dismissingSlugs.includes(post.slug);
|
||||
|
||||
const handleReadMore = post => emit('readMore', post.slug);
|
||||
const handleDismiss = post => emit('dismiss', post.slug);
|
||||
const handlePostClick = (post, index) => {
|
||||
if (index === props.currentIndex && !isPostDismissing(post)) {
|
||||
emit('imgClick', { slug: post.slug, index });
|
||||
}
|
||||
};
|
||||
|
||||
const getCardClasses = index => {
|
||||
const pos =
|
||||
(index - props.currentIndex + stackedPosts.value.length) %
|
||||
stackedPosts.value.length;
|
||||
const base =
|
||||
'relative transition-all duration-500 ease-out col-start-1 row-start-1';
|
||||
|
||||
const layers = [
|
||||
'z-50 scale-100 translate-y-0 opacity-100',
|
||||
'z-40 scale-[0.95] -translate-y-3 opacity-90',
|
||||
'z-30 scale-[0.9] -translate-y-6 opacity-70',
|
||||
'z-20 scale-[0.85] -translate-y-9 opacity-50',
|
||||
'z-10 scale-[0.8] -translate-y-12 opacity-30',
|
||||
];
|
||||
|
||||
return pos < layers.length
|
||||
? `${base} ${layers[pos]}`
|
||||
: `${base} opacity-0 scale-75 -translate-y-16`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<div class="relative grid grid-cols-1 pt-8 pb-1 px-2">
|
||||
<div
|
||||
v-for="(post, index) in stackedPosts"
|
||||
:key="post.slug || index"
|
||||
:class="getCardClasses(index)"
|
||||
>
|
||||
<StackedChangelogCard
|
||||
:card="post"
|
||||
:is-active="index === currentIndex"
|
||||
:is-dismissing="isPostDismissing(post)"
|
||||
@read-more="handleReadMore(post)"
|
||||
@dismiss="handleDismiss(post)"
|
||||
@img-click="handlePostClick(post, index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import StackedChangelogCard from './StackedChangelogCard.vue';
|
||||
|
||||
const imageCards = {
|
||||
id: 'chatwoot-captain',
|
||||
title: 'Chatwoot Captain',
|
||||
meta_title: 'Chatwoot Captain',
|
||||
meta_description:
|
||||
'Watch how our latest feature can transform your workflow with powerful automation tools.',
|
||||
slug: 'chatwoot-captain',
|
||||
feature_image:
|
||||
'https://www.chatwoot.com/images/captain/captain_thumbnail.jpg',
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
console.log(`Read more: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
console.log(`Dismissed: ${imageCards.title}`);
|
||||
};
|
||||
|
||||
const handleImgClick = () => {
|
||||
console.log(`Card clicked: ${imageCards.title}`);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/ChangelogCard/StackedChangelogCard"
|
||||
:layout="{ type: 'grid', width: '260px' }"
|
||||
>
|
||||
<Variant title="Single Card - With Image">
|
||||
<div class="p-3 bg-n-solid-2 w-56">
|
||||
<StackedChangelogCard
|
||||
:card="imageCards"
|
||||
is-active
|
||||
:is-dismissing="false"
|
||||
@read-more="handleReadMore(imageCards)"
|
||||
@dismiss="handleDismiss(imageCards)"
|
||||
@img-click="handleImgClick(imageCards)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
card: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDismissing: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['readMore', 'dismiss', 'imgClick']);
|
||||
|
||||
const handleReadMore = () => {
|
||||
emit('readMore');
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
emit('dismiss');
|
||||
};
|
||||
|
||||
const handleImgClick = () => {
|
||||
emit('imgClick');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-testid="changelog-card"
|
||||
class="flex flex-col justify-between p-3 w-full rounded-lg border shadow-sm transition-all duration-200 border-n-weak bg-n-background text-n-slate-12"
|
||||
:class="{
|
||||
'animate-fade-out pointer-events-none': isDismissing,
|
||||
'hover:shadow': isActive,
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<h5
|
||||
:title="card.meta_title"
|
||||
class="mb-1 text-sm font-semibold line-clamp-1 text-n-slate-12"
|
||||
>
|
||||
{{ card.meta_title }}
|
||||
</h5>
|
||||
<p
|
||||
:title="card.meta_description"
|
||||
class="mb-0 text-xs leading-relaxed text-n-slate-11 line-clamp-2"
|
||||
>
|
||||
{{ card.meta_description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="card.feature_image"
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<img
|
||||
:src="card.feature_image"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||
loading="lazy"
|
||||
@click.stop="handleImgClick"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="block overflow-hidden my-3 rounded-md border border-n-weak/40"
|
||||
>
|
||||
<img
|
||||
:src="card.feature_image"
|
||||
:alt="`${card.title} preview image`"
|
||||
class="object-cover w-full h-24 rounded-md cursor-pointer"
|
||||
loading="lazy"
|
||||
@click.stop="handleImgClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-1">
|
||||
<Button
|
||||
label="Read more"
|
||||
color="slate"
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handleReadMore"
|
||||
/>
|
||||
<Button
|
||||
label="Dismiss"
|
||||
color="slate"
|
||||
link
|
||||
sm
|
||||
class="text-xs font-normal hover:!no-underline"
|
||||
@click.stop="handleDismiss"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-out {
|
||||
animation: fade-out 0.2s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
@@ -96,6 +96,7 @@ watch(
|
||||
:label="selectedLabel"
|
||||
trailing-icon
|
||||
:disabled="disabled"
|
||||
no-animation
|
||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
||||
:class="{
|
||||
focused: open,
|
||||
|
||||
@@ -78,8 +78,10 @@ const handleSuggestion = opt => {
|
||||
</p>
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'captain_assistants_index',
|
||||
params: { accountId: route.params.accountId },
|
||||
name: 'captain_assistants_create_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
},
|
||||
}"
|
||||
class="text-n-slate-11 underline hover:text-n-slate-12"
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
@@ -53,14 +54,17 @@ const toggleSidebar = () => {
|
||||
v-if="showCopilotLauncher"
|
||||
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
||||
>
|
||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||
<ButtonGroup
|
||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
>
|
||||
<Button
|
||||
icon="i-woot-captain"
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||
no-animation
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||
lg
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
|
||||
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
const props = defineProps({
|
||||
menuItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
validator: value => {
|
||||
return value.every(item => item.action && item.value && item.label);
|
||||
},
|
||||
},
|
||||
menuSections: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
thumbnailSize: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
@@ -42,19 +46,62 @@ const { t } = useI18n();
|
||||
const searchInput = ref(null);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (!searchQuery.value) return props.menuItems;
|
||||
const hasSections = computed(() => props.menuSections.length > 0);
|
||||
|
||||
return props.menuItems.filter(item =>
|
||||
const flattenedMenuItems = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return props.menuItems;
|
||||
}
|
||||
|
||||
return props.menuSections.flatMap(section => section.items || []);
|
||||
});
|
||||
|
||||
const filteredMenuItems = computed(() => {
|
||||
if (!searchQuery.value) return flattenedMenuItems.value;
|
||||
|
||||
return flattenedMenuItems.value.filter(item =>
|
||||
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const filteredMenuSections = computed(() => {
|
||||
if (!hasSections.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!searchQuery.value) {
|
||||
return props.menuSections;
|
||||
}
|
||||
|
||||
const query = searchQuery.value.toLowerCase();
|
||||
|
||||
return props.menuSections
|
||||
.map(section => {
|
||||
const filteredItems = (section.items || []).filter(item =>
|
||||
item.label.toLowerCase().includes(query)
|
||||
);
|
||||
|
||||
return {
|
||||
...section,
|
||||
items: filteredItems,
|
||||
};
|
||||
})
|
||||
.filter(section => section.items.length > 0);
|
||||
});
|
||||
|
||||
const handleAction = item => {
|
||||
const { action, value, ...rest } = item;
|
||||
emit('action', { action, value, ...rest });
|
||||
};
|
||||
|
||||
const shouldShowEmptyState = computed(() => {
|
||||
if (hasSections.value) {
|
||||
return filteredMenuSections.value.length === 0;
|
||||
}
|
||||
|
||||
return filteredMenuItems.value.length === 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (searchInput.value && props.showSearch) {
|
||||
searchInput.value.focus();
|
||||
@@ -64,54 +111,122 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
|
||||
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
|
||||
:class="{
|
||||
'pt-2': !showSearch,
|
||||
}"
|
||||
>
|
||||
<div v-if="showSearch" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="filteredMenuItems.length === 0"
|
||||
v-if="showSearch"
|
||||
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
|
||||
>
|
||||
<div class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="hasSections">
|
||||
<div
|
||||
v-for="(section, sectionIndex) in filteredMenuSections"
|
||||
:key="section.title || sectionIndex"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<p
|
||||
v-if="section.title"
|
||||
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
|
||||
>
|
||||
{{ section.title }}
|
||||
</p>
|
||||
<button
|
||||
v-for="(item, itemIndex) in section.items"
|
||||
:key="item.value || itemIndex"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="sectionIndex < filteredMenuSections.length - 1"
|
||||
class="h-px bg-n-alpha-2 mx-2 my-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="(item, index) in filteredMenuItems"
|
||||
:key="index"
|
||||
type="button"
|
||||
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
|
||||
:class="{
|
||||
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
|
||||
'text-n-ruby-11': item.action === 'delete',
|
||||
'text-n-slate-12': item.action !== 'delete',
|
||||
}"
|
||||
:disabled="item.disabled"
|
||||
@click="handleAction(item)"
|
||||
>
|
||||
<slot name="thumbnail" :item="item">
|
||||
<Avatar
|
||||
v-if="item.thumbnail"
|
||||
:name="item.thumbnail.name"
|
||||
:src="item.thumbnail.src"
|
||||
:size="thumbnailSize"
|
||||
rounded-full
|
||||
/>
|
||||
</slot>
|
||||
<Icon
|
||||
v-if="item.icon"
|
||||
:icon="item.icon"
|
||||
class="flex-shrink-0 size-3.5"
|
||||
/>
|
||||
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
|
||||
<span
|
||||
v-if="item.label"
|
||||
class="min-w-0 text-sm truncate"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div
|
||||
v-if="shouldShowEmptyState"
|
||||
class="text-sm text-n-slate-11 px-2 py-1.5"
|
||||
>
|
||||
{{
|
||||
|
||||
@@ -10,6 +10,7 @@ defineProps({
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
hideActions: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
@@ -65,7 +66,7 @@ const openLink = link => {
|
||||
<div class="flex flex-col flex-1 gap-3 ltr:pr-8 rtl:pl-8">
|
||||
<p v-if="note" class="text-n-slate-12 text-sm mb-0">{{ note }}</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div v-if="!hideActions" class="flex gap-3">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
|
||||
+2
-1
@@ -13,6 +13,7 @@ defineProps({
|
||||
fallbackThumbnail: { type: String, default: '' },
|
||||
fallbackThumbnailDark: { type: String, default: '' },
|
||||
learnMoreUrl: { type: String, default: '' },
|
||||
hideActions: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const imageError = ref(false);
|
||||
@@ -92,7 +93,7 @@ const openLink = link => {
|
||||
{{ note }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3 justify-between w-full">
|
||||
<div v-if="!hideActions" class="flex gap-3 justify-between w-full">
|
||||
<slot name="actions">
|
||||
<Button
|
||||
v-if="videoUrl"
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { defineProps, computed, reactive } from 'vue';
|
||||
import { defineProps, computed, ref } from 'vue';
|
||||
import Message from './Message.vue';
|
||||
import { MESSAGE_TYPES } from './constants.js';
|
||||
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
import MessageApi from 'dashboard/api/inbox/message.js';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store.js';
|
||||
|
||||
/**
|
||||
* Props definition for the component
|
||||
@@ -40,53 +39,15 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const emit = defineEmits(['retry']);
|
||||
const store = useStore();
|
||||
|
||||
const fetchingConversations = ref(new Set());
|
||||
const allMessages = computed(() => {
|
||||
return useCamelCase(props.messages, { deep: true });
|
||||
});
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
|
||||
// Cache for fetched reply messages to avoid duplicate API calls
|
||||
const fetchedReplyMessages = reactive(new Map());
|
||||
|
||||
/**
|
||||
* Fetches a specific message from the API by trying to get messages around it
|
||||
* @param {number} messageId - The ID of the message to fetch
|
||||
* @param {number} conversationId - The ID of the conversation
|
||||
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
|
||||
*/
|
||||
const fetchReplyMessage = async (messageId, conversationId) => {
|
||||
// Return cached result if already fetched
|
||||
if (fetchedReplyMessages.has(messageId)) {
|
||||
return fetchedReplyMessages.get(messageId);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await MessageApi.getPreviousMessages({
|
||||
conversationId,
|
||||
before: messageId + 100,
|
||||
after: messageId - 100,
|
||||
});
|
||||
|
||||
const messages = response.data?.payload || [];
|
||||
const targetMessage = messages.find(msg => msg.id === messageId);
|
||||
|
||||
if (targetMessage) {
|
||||
const camelCaseMessage = useCamelCase(targetMessage);
|
||||
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
||||
return camelCaseMessage;
|
||||
}
|
||||
|
||||
// Cache null result to avoid repeated API calls
|
||||
fetchedReplyMessages.set(messageId, null);
|
||||
return null;
|
||||
} catch (error) {
|
||||
fetchedReplyMessages.set(messageId, null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if a message should be grouped with the next message
|
||||
* @param {Number} index - Index of the current message
|
||||
@@ -126,36 +87,43 @@ const shouldGroupWithNext = (index, searchList) => {
|
||||
* @returns {Object|null} - The message being replied to, or null if not found
|
||||
*/
|
||||
const getInReplyToMessage = parentMessage => {
|
||||
if (!parentMessage) return null;
|
||||
|
||||
const inReplyToMessageId =
|
||||
parentMessage.contentAttributes?.inReplyTo ??
|
||||
parentMessage.content_attributes?.in_reply_to;
|
||||
parentMessage?.contentAttributes?.inReplyTo ??
|
||||
parentMessage?.content_attributes?.in_reply_to;
|
||||
|
||||
if (!inReplyToMessageId) return null;
|
||||
|
||||
// Try to find in current messages first
|
||||
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
|
||||
// 1. Check props messages (already camelCased via allMessages)
|
||||
const foundInProps = allMessages.value?.find(
|
||||
m => m.id === inReplyToMessageId
|
||||
);
|
||||
if (foundInProps) return foundInProps;
|
||||
|
||||
// Then try store messages
|
||||
if (!replyMessage && currentChat.value?.messages) {
|
||||
replyMessage = currentChat.value.messages.find(
|
||||
msg => msg.id === inReplyToMessageId
|
||||
);
|
||||
// 2. Check store messages
|
||||
const foundInStore = currentChat.value?.messages?.find(
|
||||
m => m.id === inReplyToMessageId
|
||||
);
|
||||
if (foundInStore) return useCamelCase(foundInStore);
|
||||
|
||||
// 3. Fetch if not currently fetching for this conversation
|
||||
const conversationId = currentChat.value?.id;
|
||||
if (
|
||||
conversationId &&
|
||||
!currentChat.value.allMessagesLoaded &&
|
||||
!fetchingConversations.value.has(conversationId)
|
||||
) {
|
||||
fetchingConversations.value.add(conversationId);
|
||||
store
|
||||
.dispatch('fetchPreviousMessages', {
|
||||
conversationId,
|
||||
before: currentChat.value.messages?.[0]?.id ?? null,
|
||||
})
|
||||
.finally(() => {
|
||||
fetchingConversations.value.delete(conversationId);
|
||||
});
|
||||
}
|
||||
|
||||
// Then check fetch cache
|
||||
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
||||
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
|
||||
}
|
||||
|
||||
// If still not found and we have conversation context, fetch it
|
||||
if (!replyMessage && currentChat.value?.id) {
|
||||
fetchReplyMessage(inReplyToMessageId, currentChat.value.id);
|
||||
return null; // Let UI handle loading state
|
||||
}
|
||||
|
||||
return replyMessage ? useCamelCase(replyMessage) : null;
|
||||
return null;
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -47,6 +47,7 @@ const isReel = computed(() => {
|
||||
'max-w-48': isReel,
|
||||
'max-w-full': !isReel,
|
||||
}"
|
||||
@click.stop
|
||||
@error="handleError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
|
||||
defineProps({
|
||||
isMobileSidebarOpen: {
|
||||
@@ -45,14 +46,17 @@ const toggleSidebar = () => {
|
||||
},
|
||||
]"
|
||||
>
|
||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
||||
<ButtonGroup
|
||||
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||
>
|
||||
<Button
|
||||
icon="i-lucide-menu"
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
||||
no-animation
|
||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||
lg
|
||||
@click="toggleSidebar"
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<template v-else />
|
||||
</template>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidebarGroup from './SidebarGroup.vue';
|
||||
import SidebarProfileMenu from './SidebarProfileMenu.vue';
|
||||
import SidebarChangelogCard from './SidebarChangelogCard.vue';
|
||||
import ChannelLeaf from './ChannelLeaf.vue';
|
||||
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
|
||||
import Logo from 'next/icon/Logo.vue';
|
||||
@@ -32,11 +33,15 @@ const emit = defineEmits([
|
||||
'closeMobileSidebar',
|
||||
]);
|
||||
|
||||
const { accountScopedRoute } = useAccount();
|
||||
const { accountScopedRoute, isOnChatwootCloud } = useAccount();
|
||||
const store = useStore();
|
||||
const searchShortcut = useKbd([`$mod`, 'k']);
|
||||
const { t } = useI18n();
|
||||
|
||||
const isACustomBrandedInstance = useMapGetter(
|
||||
'globalConfig/isACustomBrandedInstance'
|
||||
);
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -216,26 +221,70 @@ const menuItems = computed(() => {
|
||||
name: 'Captain',
|
||||
icon: 'i-woot-captain',
|
||||
label: t('SIDEBAR.CAPTAIN'),
|
||||
activeOn: ['captain_assistants_create_index'],
|
||||
children: [
|
||||
{
|
||||
name: 'Assistants',
|
||||
label: t('SIDEBAR.CAPTAIN_ASSISTANTS'),
|
||||
to: accountScopedRoute('captain_assistants_index'),
|
||||
name: 'FAQs',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
activeOn: [
|
||||
'captain_assistants_responses_index',
|
||||
'captain_assistants_responses_pending',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_responses_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
label: t('SIDEBAR.CAPTAIN_DOCUMENTS'),
|
||||
to: accountScopedRoute('captain_documents_index'),
|
||||
activeOn: ['captain_assistants_documents_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_documents_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Responses',
|
||||
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
|
||||
to: accountScopedRoute('captain_responses_index'),
|
||||
name: 'Scenarios',
|
||||
label: t('SIDEBAR.CAPTAIN_SCENARIOS'),
|
||||
activeOn: ['captain_assistants_scenarios_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_scenarios_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Playground',
|
||||
label: t('SIDEBAR.CAPTAIN_PLAYGROUND'),
|
||||
activeOn: ['captain_assistants_playground_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_playground_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Inboxes',
|
||||
label: t('SIDEBAR.CAPTAIN_INBOXES'),
|
||||
activeOn: ['captain_assistants_inboxes_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
to: accountScopedRoute('captain_tools_index'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
activeOn: [
|
||||
'captain_assistants_settings_index',
|
||||
'captain_assistants_guidelines_index',
|
||||
'captain_assistants_guardrails_index',
|
||||
],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_assistants_settings_index',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -532,20 +581,20 @@ const menuItems = computed(() => {
|
||||
]"
|
||||
>
|
||||
<section class="grid gap-2 mt-2 mb-4">
|
||||
<div class="flex items-center min-w-0 gap-2 px-2">
|
||||
<div class="grid flex-shrink-0 size-6 place-content-center">
|
||||
<div class="flex gap-2 items-center px-2 min-w-0">
|
||||
<div class="grid flex-shrink-0 place-content-center size-6">
|
||||
<Logo class="size-4" />
|
||||
</div>
|
||||
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
|
||||
<SidebarAccountSwitcher
|
||||
class="flex-grow min-w-0 -mx-1"
|
||||
class="flex-grow -mx-1 min-w-0"
|
||||
@show-create-account-modal="emit('showCreateAccountModal')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 px-2">
|
||||
<RouterLink
|
||||
:to="{ name: 'search' }"
|
||||
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||
class="flex gap-2 items-center px-2 py-1 w-full h-7 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
|
||||
>
|
||||
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
|
||||
<span class="flex-grow text-left">
|
||||
@@ -570,7 +619,7 @@ const menuItems = computed(() => {
|
||||
</ComposeConversation>
|
||||
</div>
|
||||
</section>
|
||||
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
|
||||
<nav class="grid overflow-y-scroll flex-grow gap-2 px-2 pb-5 no-scrollbar">
|
||||
<ul class="flex flex-col gap-1.5 m-0 list-none">
|
||||
<SidebarGroup
|
||||
v-for="item in menuItems"
|
||||
@@ -580,11 +629,21 @@ const menuItems = computed(() => {
|
||||
</ul>
|
||||
</nav>
|
||||
<section
|
||||
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
|
||||
class="flex flex-col flex-shrink-0 relative gap-1 justify-between items-center"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 -top-[31px] h-8 bg-gradient-to-t from-n-solid-2 to-transparent"
|
||||
/>
|
||||
<SidebarChangelogCard
|
||||
v-if="isOnChatwootCloud && !isACustomBrandedInstance"
|
||||
/>
|
||||
<div
|
||||
class="p-1 flex-shrink-0 flex w-full justify-between z-10 gap-2 items-center border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)]"
|
||||
>
|
||||
<SidebarProfileMenu
|
||||
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import GroupedStackedChangelogCard from 'dashboard/components-next/changelog-card/GroupedStackedChangelogCard.vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import changelogAPI from 'dashboard/api/changelog';
|
||||
|
||||
const MAX_DISMISSED_SLUGS = 5;
|
||||
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
const posts = ref([]);
|
||||
const currentIndex = ref(0);
|
||||
const dismissingCards = ref([]);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// Get current dismissed slugs from ui_settings
|
||||
const dismissedSlugs = computed(() => {
|
||||
return uiSettings.value.changelog_dismissed_slugs || [];
|
||||
});
|
||||
|
||||
// Get un dismissed posts - these are the changelog posts that should be shown
|
||||
const unDismissedPosts = computed(() => {
|
||||
return posts.value.filter(post => !dismissedSlugs.value.includes(post.slug));
|
||||
});
|
||||
|
||||
// Fetch changelog posts from API
|
||||
const fetchChangelog = async () => {
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await changelogAPI.fetchFromHub();
|
||||
posts.value = response.data.posts || [];
|
||||
|
||||
// Clean up dismissed slugs - remove any that are no longer in the current feed
|
||||
const currentSlugs = posts.value.map(post => post.slug);
|
||||
const cleanedDismissedSlugs = dismissedSlugs.value.filter(slug =>
|
||||
currentSlugs.includes(slug)
|
||||
);
|
||||
|
||||
// Update ui_settings if cleanup occurred
|
||||
if (cleanedDismissedSlugs.length !== dismissedSlugs.value.length) {
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: cleanedDismissedSlugs,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (err) {
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Dismiss a changelog post
|
||||
const dismissPost = slug => {
|
||||
const currentDismissed = [...dismissedSlugs.value];
|
||||
|
||||
// Add new slug if not already present
|
||||
if (!currentDismissed.includes(slug)) {
|
||||
currentDismissed.push(slug);
|
||||
|
||||
// Keep only the most recent MAX_DISMISSED_SLUGS entries
|
||||
if (currentDismissed.length > MAX_DISMISSED_SLUGS) {
|
||||
currentDismissed.shift(); // Remove oldest entry
|
||||
}
|
||||
|
||||
updateUISettings({
|
||||
changelog_dismissed_slugs: currentDismissed,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = slug => {
|
||||
dismissingCards.value.push(slug);
|
||||
setTimeout(() => {
|
||||
dismissPost(slug);
|
||||
dismissingCards.value = dismissingCards.value.filter(s => s !== slug);
|
||||
if (currentIndex.value >= unDismissedPosts.value.length)
|
||||
currentIndex.value = 0;
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const handleReadMore = () => {
|
||||
const currentPost = unDismissedPosts.value[currentIndex.value];
|
||||
if (currentPost?.slug) {
|
||||
window.open(`https://www.chatwoot.com/blog/${currentPost.slug}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImgClick = ({ index }) => {
|
||||
currentIndex.value = index;
|
||||
handleReadMore();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchChangelog();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GroupedStackedChangelogCard
|
||||
v-if="unDismissedPosts.length > 0"
|
||||
:posts="unDismissedPosts"
|
||||
:current-index="currentIndex"
|
||||
:dismissing-slugs="dismissingCards"
|
||||
class="min-h-[240px] z-10"
|
||||
@read-more="handleReadMore"
|
||||
@dismiss="handleDismiss"
|
||||
@img-click="handleImgClick"
|
||||
/>
|
||||
<template v-else />
|
||||
</template>
|
||||
@@ -19,19 +19,46 @@ export function useSidebarContext() {
|
||||
return '/';
|
||||
};
|
||||
|
||||
// Helper to find route definition by name without resolving
|
||||
const findRouteByName = name => {
|
||||
const routes = router.getRoutes();
|
||||
return routes.find(route => route.name === name);
|
||||
};
|
||||
|
||||
const resolvePermissions = to => {
|
||||
if (to) return router.resolve(to)?.meta?.permissions ?? [];
|
||||
return [];
|
||||
if (!to) return [];
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.permissions ?? [];
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.permissions ?? [];
|
||||
};
|
||||
|
||||
const resolveFeatureFlag = to => {
|
||||
if (to) return router.resolve(to)?.meta?.featureFlag || '';
|
||||
return '';
|
||||
if (!to) return '';
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.featureFlag || '';
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.featureFlag || '';
|
||||
};
|
||||
|
||||
const resolveInstallationType = to => {
|
||||
if (to) return router.resolve(to)?.meta?.installationTypes || [];
|
||||
return [];
|
||||
if (!to) return [];
|
||||
|
||||
// If navigationPath param exists, get the target route definition
|
||||
if (to.params?.navigationPath) {
|
||||
const targetRoute = findRouteByName(to.params.navigationPath);
|
||||
return targetRoute?.meta?.installationTypes || [];
|
||||
}
|
||||
|
||||
return router.resolve(to)?.meta?.installationTypes || [];
|
||||
};
|
||||
|
||||
const isAllowed = to => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
initialActiveTab: {
|
||||
type: Number,
|
||||
@@ -22,6 +24,32 @@ const emit = defineEmits(['tabChanged']);
|
||||
|
||||
const activeTab = computed(() => props.initialActiveTab);
|
||||
|
||||
const tabRefs = ref([]);
|
||||
const indicatorStyle = ref({});
|
||||
const enableTransition = ref(false);
|
||||
|
||||
const activeElement = computed(() => tabRefs.value[activeTab.value]);
|
||||
|
||||
const updateIndicator = () => {
|
||||
if (!activeElement.value) return;
|
||||
|
||||
indicatorStyle.value = {
|
||||
left: `${activeElement.value.offsetLeft}px`,
|
||||
width: `${activeElement.value.offsetWidth}px`,
|
||||
};
|
||||
};
|
||||
|
||||
useResizeObserver(activeElement, () => {
|
||||
if (enableTransition.value || !activeElement.value) updateIndicator();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
updateIndicator();
|
||||
nextTick(() => {
|
||||
enableTransition.value = true;
|
||||
});
|
||||
});
|
||||
|
||||
const selectTab = index => {
|
||||
emit('tabChanged', props.tabs[index]);
|
||||
};
|
||||
@@ -37,20 +65,30 @@ const showDivider = index => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
|
||||
<div
|
||||
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
|
||||
>
|
||||
<div
|
||||
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
|
||||
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
|
||||
:style="indicatorStyle"
|
||||
/>
|
||||
|
||||
<template v-for="(tab, index) in tabs" :key="index">
|
||||
<button
|
||||
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
|
||||
:ref="el => (tabRefs[index] = el)"
|
||||
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
||||
:class="[
|
||||
activeTab === index
|
||||
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
|
||||
: 'text-n-slate-10 outline-transparent h-8',
|
||||
? 'text-n-blue-text scale-100'
|
||||
: 'text-n-slate-10 scale-[0.98]',
|
||||
]"
|
||||
@click="selectTab(index)"
|
||||
>
|
||||
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
||||
</button>
|
||||
<div
|
||||
v-if="index < tabs.length - 1"
|
||||
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
||||
:class="
|
||||
showDivider(index)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const store = useStore();
|
||||
@@ -133,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div
|
||||
<ButtonGroup
|
||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||
>
|
||||
@@ -142,6 +143,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
|
||||
size="sm"
|
||||
color="slate"
|
||||
no-animation
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||
:is-loading="isLoading"
|
||||
@click="onCmdResolveConversation"
|
||||
@@ -151,6 +153,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
|
||||
size="sm"
|
||||
color="slate"
|
||||
no-animation
|
||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||
:is-loading="isLoading"
|
||||
@click="onCmdOpenConversation"
|
||||
@@ -160,6 +163,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
|
||||
size="sm"
|
||||
color="slate"
|
||||
no-animation
|
||||
:is-loading="isLoading"
|
||||
@click="onCmdOpenConversation"
|
||||
/>
|
||||
@@ -169,12 +173,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
icon="i-lucide-chevron-down"
|
||||
:disabled="isLoading"
|
||||
size="sm"
|
||||
no-animation
|
||||
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
@click="openDropdown"
|
||||
/>
|
||||
</div>
|
||||
</ButtonGroup>
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
|
||||
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
|
||||
}
|
||||
|
||||
function handleEmptyBodyWithSignature() {
|
||||
const { schema, tr } = state;
|
||||
const { schema, tr, doc } = state;
|
||||
|
||||
const isEmptyParagraph = node =>
|
||||
node && node.type === schema.nodes.paragraph && node.content.size === 0;
|
||||
|
||||
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
|
||||
if (isEmptyParagraph(doc.firstChild)) {
|
||||
focusEditorInputField('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// create a paragraph node and
|
||||
// start a transaction to append it at the end
|
||||
|
||||
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
|
||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'cursor-not-allowed': disabled,
|
||||
|
||||
+92
-159
@@ -1,100 +1,129 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['update', 'close', 'assign'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedLabels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ labels: 'labels/getLabels' }),
|
||||
filteredLabels() {
|
||||
return this.labels.filter(label =>
|
||||
label.title.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isLabelSelected(label) {
|
||||
return this.selectedLabels.includes(label);
|
||||
},
|
||||
assignLabels(key) {
|
||||
this.$emit('update', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const query = ref('');
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
if (!query.value) return labels.value;
|
||||
return labels.value.filter(label =>
|
||||
label.title.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const hasLabels = computed(() => labels.value.length > 0);
|
||||
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
|
||||
|
||||
const isLabelSelected = label => {
|
||||
return selectedLabels.value.includes(label);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onClose" class="labels-container">
|
||||
<div
|
||||
v-on-click-outside="onClose"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
|
||||
role="dialog"
|
||||
aria-labelledby="label-dialog-title"
|
||||
>
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
|
||||
<div class="flex items-center justify-between p-2.5">
|
||||
<span class="text-sm font-medium">{{
|
||||
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
}}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="labels-list">
|
||||
<header class="labels-list__header">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 label-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm label--search_input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col max-h-60 min-h-0">
|
||||
<header class="py-2 px-2.5">
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
icon-left="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</header>
|
||||
<ul class="labels-list__body">
|
||||
<ul
|
||||
v-if="hasLabels"
|
||||
class="flex-1 overflow-y-auto m-0 list-none"
|
||||
role="listbox"
|
||||
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
>
|
||||
<li v-if="!hasFilteredLabels" class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="label__list-item"
|
||||
class="my-1 mx-0 py-0 px-2.5"
|
||||
role="option"
|
||||
:aria-selected="isLabelSelected(label.title)"
|
||||
>
|
||||
<label
|
||||
class="item"
|
||||
:class="{ 'label-selected': isLabelSelected(label.title) }"
|
||||
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="label-checkbox"
|
||||
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
|
||||
:aria-label="label.title"
|
||||
/>
|
||||
<span
|
||||
class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
|
||||
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<span
|
||||
class="label-pill"
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="labels-list__footer">
|
||||
<div v-else class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</div>
|
||||
<footer class="p-2">
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="$emit('assign', selectedLabels)"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -102,107 +131,11 @@ export default {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.labels-list {
|
||||
@apply flex flex-col max-h-[15rem] min-h-[auto];
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
.labels-list__header {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
|
||||
.labels-list__body {
|
||||
@apply flex-1 overflow-y-auto py-2.5 mx-0;
|
||||
}
|
||||
|
||||
.labels-list__footer {
|
||||
@apply p-2;
|
||||
|
||||
button {
|
||||
@apply w-full;
|
||||
|
||||
.button__content {
|
||||
@apply text-center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-list-search {
|
||||
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
|
||||
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.label--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-container {
|
||||
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply max-h-[15rem] overflow-y-auto;
|
||||
|
||||
.label__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
}
|
||||
|
||||
.labels-placeholder {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.label__list-item {
|
||||
@apply my-1 mx-0 py-0 px-2.5;
|
||||
|
||||
.item {
|
||||
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
|
||||
&.label-selected {
|
||||
@apply bg-n-slate-2;
|
||||
}
|
||||
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.label-checkbox {
|
||||
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
@apply flex-grow w-full;
|
||||
}
|
||||
|
||||
.label-pill {
|
||||
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* This is a shared composables that holds utilites used to build dropdown and file options
|
||||
* This is a shared composables that holds utilities used to build dropdown and file options
|
||||
* @returns {Object} An object containing various automation-related functions and computed properties.
|
||||
*/
|
||||
export default function useAutomationValues() {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
|
||||
enable_audio_alerts: audioAlertType,
|
||||
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
|
||||
notification_tone: audioAlertTone,
|
||||
// UI Settings can be undefined initally as we don't send the
|
||||
// UI Settings can be undefined initially as we don't send the
|
||||
// entire payload for the user during the signup process.
|
||||
} = uiSettings || {};
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found for",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
|
||||
@@ -571,6 +571,27 @@
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "በአሁኑ ጊዜ ንቁ እውቂያዎች የሉም 🌙"
|
||||
}
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"DELETE_CONTACTS": "Delete",
|
||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
||||
"DELETE_FAILED": "Failed to delete contacts.",
|
||||
"DELETE_DIALOG": {
|
||||
"TITLE": "Delete selected contacts",
|
||||
"SINGULAR_TITLE": "Delete selected contact",
|
||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
||||
"CONFIRM_SINGLE": "Delete contact"
|
||||
}
|
||||
},
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
"CONTACT_SEARCH": {
|
||||
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
|
||||
|
||||
@@ -648,14 +648,16 @@
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h"
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined"
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
|
||||
@@ -752,8 +752,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "None",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "Password",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "Type"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "Number",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "Required"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -793,6 +903,10 @@
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
"TITLE": "Add an FAQ",
|
||||
@@ -823,13 +937,15 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "Edit",
|
||||
"DELETE_RESPONSE": "Delete"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain FAQ",
|
||||
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_14_DAYS": "Last 14 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"LAST_3_MONTHS": "Last 3 months",
|
||||
"LAST_6_MONTHS": "Last 6 months",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "Download inbox reports",
|
||||
"FILTER_DROPDOWN_LABEL": "Select Inbox",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "Conversations",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "Download report"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "Conversations by agents",
|
||||
"LOADING_MESSAGE": "Loading agent metrics...",
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"CAPTAIN_ASSISTANTS": "Assistants",
|
||||
"CAPTAIN_DOCUMENTS": "Documents",
|
||||
"CAPTAIN_RESPONSES": "FAQs",
|
||||
"CAPTAIN_TOOLS": "Tools",
|
||||
"HOME": "Home",
|
||||
"AGENTS": "Agents",
|
||||
"AGENT_BOTS": "Bots",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "إضافة وسم",
|
||||
"NO_LABELS_FOUND": "لم يتم العثور على تسميات لـ",
|
||||
"NO_LABELS_FOUND": "لم يتم العثور على تصنيفات",
|
||||
"ASSIGN_SELECTED_LABELS": "تعيين التسميات المحددة",
|
||||
"ASSIGN_SUCCESFUL": "تم تعيين التسميات بنجاح.",
|
||||
"ASSIGN_FAILED": "فشل في تعيين التسميات ، الرجاء المحاولة مرة أخرى."
|
||||
|
||||
@@ -571,6 +571,27 @@
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "تعيين التسميات",
|
||||
"ASSIGN_LABELS_SUCCESS": "تم تعيين التسميات بنجاح.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"DELETE_CONTACTS": "حذف",
|
||||
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
||||
"DELETE_FAILED": "Failed to delete contacts.",
|
||||
"DELETE_DIALOG": {
|
||||
"TITLE": "Delete selected contacts",
|
||||
"SINGULAR_TITLE": "Delete selected contact",
|
||||
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
||||
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
||||
"CONFIRM_MULTIPLE": "Delete contacts",
|
||||
"CONFIRM_SINGLE": "حذف جهة الاتصال"
|
||||
}
|
||||
},
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
"CONTACT_SEARCH": {
|
||||
"ERROR_MESSAGE": "We couldn’t complete the search. Please try again."
|
||||
|
||||
@@ -648,14 +648,16 @@
|
||||
"TIER_1K": "1K customers per 24h",
|
||||
"TIER_10K": "10K customers per 24h",
|
||||
"TIER_100K": "100K customers per 24h",
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h"
|
||||
"TIER_UNLIMITED": "Unlimited customers per 24h",
|
||||
"UNKNOWN": "Rating not available"
|
||||
},
|
||||
"STATUSES": {
|
||||
"APPROVED": "Approved",
|
||||
"PENDING_REVIEW": "Pending Review",
|
||||
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
|
||||
"REJECTED": "Rejected",
|
||||
"DECLINED": "Declined"
|
||||
"DECLINED": "Declined",
|
||||
"NON_EXISTS": "Non exists"
|
||||
},
|
||||
"MODES": {
|
||||
"SANDBOX": "Sandbox",
|
||||
|
||||
@@ -752,8 +752,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Custom Tools",
|
||||
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
|
||||
}
|
||||
},
|
||||
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
|
||||
"OPTIONS": {
|
||||
"EDIT_TOOL": "Edit tool",
|
||||
"DELETE_TOOL": "Delete tool"
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool created successfully",
|
||||
"ERROR_MESSAGE": "Failed to create custom tool"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit Custom Tool",
|
||||
"SUCCESS_MESSAGE": "Custom tool updated successfully",
|
||||
"ERROR_MESSAGE": "Failed to update custom tool"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Custom Tool",
|
||||
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
|
||||
"CONFIRM": "نعم، احذف",
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "الوصف",
|
||||
"PLACEHOLDER": "Looks up order details by order ID"
|
||||
},
|
||||
"HTTP_METHOD": {
|
||||
"LABEL": "Method"
|
||||
},
|
||||
"ENDPOINT_URL": {
|
||||
"LABEL": "Endpoint URL",
|
||||
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
|
||||
"ERROR": "Valid URL is required"
|
||||
},
|
||||
"AUTH_TYPE": {
|
||||
"LABEL": "Authentication Type"
|
||||
},
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "لا شيء",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "مفتاح API"
|
||||
},
|
||||
"AUTH_CONFIG": {
|
||||
"BEARER_TOKEN": "Bearer Token",
|
||||
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
|
||||
"USERNAME": "Username",
|
||||
"USERNAME_PLACEHOLDER": "Enter username",
|
||||
"PASSWORD": "كلمة المرور",
|
||||
"PASSWORD_PLACEHOLDER": "Enter password",
|
||||
"API_KEY": "Header Name",
|
||||
"API_KEY_PLACEHOLDER": "X-API-Key",
|
||||
"API_VALUE": "Header Value",
|
||||
"API_VALUE_PLACEHOLDER": "Enter API key value"
|
||||
},
|
||||
"PARAMETERS": {
|
||||
"LABEL": "Parameters",
|
||||
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
|
||||
},
|
||||
"ADD_PARAMETER": "Add Parameter",
|
||||
"PARAM_NAME": {
|
||||
"PLACEHOLDER": "Parameter name (e.g., order_id)"
|
||||
},
|
||||
"PARAM_TYPE": {
|
||||
"PLACEHOLDER": "النوع"
|
||||
},
|
||||
"PARAM_TYPES": {
|
||||
"STRING": "String",
|
||||
"NUMBER": "العدد",
|
||||
"BOOLEAN": "Boolean",
|
||||
"ARRAY": "Array",
|
||||
"OBJECT": "Object"
|
||||
},
|
||||
"PARAM_DESCRIPTION": {
|
||||
"PLACEHOLDER": "Description of the parameter"
|
||||
},
|
||||
"PARAM_REQUIRED": {
|
||||
"LABEL": "مطلوب"
|
||||
},
|
||||
"REQUEST_TEMPLATE": {
|
||||
"LABEL": "Request Body Template (Optional)",
|
||||
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
|
||||
},
|
||||
"RESPONSE_TEMPLATE": {
|
||||
"LABEL": "Response Template (Optional)",
|
||||
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
|
||||
},
|
||||
"ERRORS": {
|
||||
"PARAM_NAME_REQUIRED": "Parameter name is required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -793,6 +903,10 @@
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "الكل"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
"TITLE": "Add an FAQ",
|
||||
@@ -823,13 +937,15 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "تعديل",
|
||||
"DELETE_RESPONSE": "حذف"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain FAQ",
|
||||
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
},
|
||||
"DATE_RANGE_OPTIONS": {
|
||||
"LAST_7_DAYS": "آخر 7 أيام",
|
||||
"LAST_14_DAYS": "آخر 14 يوماً",
|
||||
"LAST_30_DAYS": "آخر 30 يوماً",
|
||||
"LAST_3_MONTHS": "آخر 3 أشهر",
|
||||
"LAST_6_MONTHS": "آخر 6 أشهر",
|
||||
@@ -266,6 +267,8 @@
|
||||
"NO_ENOUGH_DATA": "لم يتم جمع بيانات بقدر كافي لإنشاء التقرير، الرجاء المحاولة مرة أخرى لاحقاً.",
|
||||
"DOWNLOAD_INBOX_REPORTS": "تحميل تقارير صندوق الوارد",
|
||||
"FILTER_DROPDOWN_LABEL": "اختر صندوق الوارد",
|
||||
"ALL_INBOXES": "All Inboxes",
|
||||
"SEARCH_INBOX": "Search Inbox",
|
||||
"METRICS": {
|
||||
"CONVERSATIONS": {
|
||||
"NAME": "المحادثات",
|
||||
@@ -467,6 +470,13 @@
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "تنزيل التقرير"
|
||||
},
|
||||
"RESOLUTION_HEATMAP": {
|
||||
"HEADER": "Resolutions",
|
||||
"NO_CONVERSATIONS": "No conversations",
|
||||
"CONVERSATION": "{count} conversation",
|
||||
"CONVERSATIONS": "{count} conversations",
|
||||
"DOWNLOAD_REPORT": "تنزيل التقرير"
|
||||
},
|
||||
"AGENT_CONVERSATIONS": {
|
||||
"HEADER": "المحادثات من قبل الوكلاء",
|
||||
"LOADING_MESSAGE": "جاري تحميل مقاييس الوكيل...",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user