Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55d089f01 | ||
|
|
d0e84373ee | ||
|
|
62152321bc | ||
|
|
c6714b20c2 | ||
|
|
0f2263ec94 | ||
|
|
784e0468c3 | ||
|
|
75c9b38099 | ||
|
|
f8c155dd9e | ||
|
|
b9cf410b1f | ||
|
|
e6d96b14ac | ||
|
|
f0b3f025ff | ||
|
|
5b32761d72 | ||
|
|
7146471270 | ||
|
|
8d6ea64848 | ||
|
|
27b0a22019 | ||
|
|
611635d3d7 | ||
|
|
02178eb930 | ||
|
|
a1413d1656 | ||
|
|
a1747eeb62 | ||
|
|
f90a324ddf | ||
|
|
3f5d4f46f8 | ||
|
|
7ae916e2fc | ||
|
|
c623d6079b | ||
|
|
f0876e9877 | ||
|
|
92b0c2d4f5 | ||
|
|
0a80076387 | ||
|
|
a59f53d00d | ||
|
|
143f394a18 | ||
|
|
8e98fd26b5 | ||
|
|
4c85a1c6c0 | ||
|
|
6e28c4fcf8 | ||
|
|
7f9d711845 | ||
|
|
31436412fb | ||
|
|
84d2135a04 | ||
|
|
5bd739739e | ||
|
|
3b42b6c3bb | ||
|
|
d3b8edd522 | ||
|
|
ac7cac33a8 | ||
|
|
4d98b1871a | ||
|
|
4f214dd77a | ||
|
|
94640628eb | ||
|
|
c9c9a011fe | ||
|
|
747a856181 | ||
|
|
be5248ad68 | ||
|
|
a9ead61a89 | ||
|
|
c5b72239c6 | ||
|
|
824b5a8113 | ||
|
|
913ff2821a | ||
|
|
05c004a3cb | ||
|
|
bd4c572aea | ||
|
|
7e672e5c71 | ||
|
|
1622ba1b7f | ||
|
|
d59f2d7bd1 | ||
|
|
959f7447c3 | ||
|
|
39a6485c2a | ||
|
|
bcaca9d3d3 | ||
|
|
4de56468b7 | ||
|
|
6f84899beb | ||
|
|
bf78dde488 | ||
|
|
1df0159f65 | ||
|
|
b2a1e3282c |
@@ -21,6 +21,7 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'octokit'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
@@ -591,6 +591,9 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
@@ -817,6 +820,9 @@ GEM
|
||||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
scout_apm (5.3.3)
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
@@ -1057,6 +1063,7 @@ DEPENDENCIES
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
octokit
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook
|
||||
before_action :ensure_hook_exists, except: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def repositories
|
||||
repositories = github_service.repositories
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def search_repositories
|
||||
repositories = github_service.search_repositories(params[:q])
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def assignees
|
||||
assignees = github_service.assignees(repo_full_name)
|
||||
filtered_assignees = assignees.map do |assignee|
|
||||
{
|
||||
login: assignee.login,
|
||||
avatar_url: assignee.avatar_url
|
||||
}
|
||||
end
|
||||
render json: filtered_assignees
|
||||
end
|
||||
|
||||
def labels
|
||||
labels = github_service.labels(repo_full_name)
|
||||
filtered_labels = labels.map do |label|
|
||||
{
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
end
|
||||
render json: filtered_labels
|
||||
end
|
||||
|
||||
def search_issues
|
||||
issues = github_service.search_issues(repo_full_name, params[:q])
|
||||
filtered_issues = issues.map do |issue|
|
||||
{
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
html_url: issue.html_url
|
||||
}
|
||||
end
|
||||
render json: filtered_issues
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue_data = github_service.create_issue(
|
||||
repo_full_name,
|
||||
params[:title],
|
||||
params[:body],
|
||||
issue_options
|
||||
)
|
||||
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def link_issue
|
||||
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def linked_issues
|
||||
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
||||
|
||||
render json: issues.map do |issue|
|
||||
{
|
||||
id: issue.id,
|
||||
issue_number: issue.issue_number,
|
||||
title: issue.issue_title,
|
||||
html_url: issue.html_url,
|
||||
repo_full_name: issue.repo_full_name,
|
||||
linked_by: {
|
||||
id: issue.linked_by.id,
|
||||
name: issue.linked_by.name
|
||||
},
|
||||
created_at: issue.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
github_issue = GithubIssue.find(params[:id])
|
||||
github_issue.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
||||
end
|
||||
|
||||
def ensure_hook_exists
|
||||
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
||||
end
|
||||
|
||||
def github_service
|
||||
@github_service ||= Github::GithubService.new(hook: @hook)
|
||||
end
|
||||
|
||||
def repo_full_name
|
||||
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
||||
end
|
||||
|
||||
def issue_options
|
||||
options = {}
|
||||
options[:assignees] = params[:assignees] if params[:assignees].present?
|
||||
options[:labels] = params[:labels] if params[:labels].present?
|
||||
options
|
||||
end
|
||||
|
||||
def create_linked_issue(issue_data)
|
||||
GithubIssue.create!(
|
||||
conversation_id: params[:conversation_id],
|
||||
account: Current.account,
|
||||
repo_full_name: repo_full_name,
|
||||
issue_number: issue_data.number,
|
||||
issue_title: issue_data.title,
|
||||
html_url: issue_data.html_url,
|
||||
linked_by: Current.user
|
||||
)
|
||||
end
|
||||
|
||||
def issue_response(github_issue)
|
||||
{
|
||||
id: github_issue.id,
|
||||
issue_number: github_issue.issue_number,
|
||||
title: github_issue.issue_title,
|
||||
html_url: github_issue.html_url,
|
||||
repo_full_name: github_issue.repo_full_name
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,160 @@
|
||||
class Github::CallbacksController < ApplicationController
|
||||
include Github::IntegrationHelper
|
||||
|
||||
def show
|
||||
# Validate account context early for all flows that require it
|
||||
account if params[:code].present?
|
||||
|
||||
if params[:installation_id].present? && params[:code].present?
|
||||
# Both installation and OAuth code present - handle both
|
||||
handle_installation_with_oauth
|
||||
elsif params[:installation_id].present?
|
||||
# Only installation_id present - redirect to OAuth
|
||||
handle_installation
|
||||
else
|
||||
# Only OAuth code present - handle authorization
|
||||
handle_authorization
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_installation_with_oauth
|
||||
# Handle both installation and OAuth in one go
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response(installation_id)
|
||||
end
|
||||
|
||||
def handle_installation
|
||||
if params[:setup_action] == 'install'
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
redirect_to build_oauth_url(installation_id)
|
||||
else
|
||||
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
||||
redirect_to github_integration_settings_url
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response
|
||||
end
|
||||
|
||||
def build_oauth_url(installation_id)
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# Store installation_id in session for later use
|
||||
session[:github_installation_id] = installation_id
|
||||
|
||||
# For now, redirect to a page that will initiate OAuth with proper account context
|
||||
# This is a temporary solution until we have a proper account-agnostic setup
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
|
||||
OAuth2::Client.new(
|
||||
app_id,
|
||||
app_secret,
|
||||
{
|
||||
site: 'https://github.com',
|
||||
token_url: '/login/oauth/access_token',
|
||||
authorize_url: '/login/oauth/authorize'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(installation_id = nil)
|
||||
settings = build_hook_settings(installation_id)
|
||||
hook = create_integration_hook(settings)
|
||||
hook.save!
|
||||
|
||||
cleanup_session_data
|
||||
redirect_to github_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
def build_hook_settings(installation_id)
|
||||
settings = {
|
||||
token_type: parsed_body['token_type'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
|
||||
settings[:installation_id] = installation_id || session[:github_installation_id]
|
||||
settings.compact
|
||||
end
|
||||
|
||||
def create_integration_hook(settings)
|
||||
account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'github',
|
||||
settings: settings
|
||||
)
|
||||
end
|
||||
|
||||
def cleanup_session_data
|
||||
session.delete(:github_installation_id)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= account_from_state
|
||||
end
|
||||
|
||||
def account_from_state
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
# Try signed GlobalID first (installation flow)
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
return account if account
|
||||
|
||||
# Fallback to JWT token (direct OAuth flow)
|
||||
account_id = verify_github_token(params[:state])
|
||||
return Account.find(account_id) if account_id
|
||||
|
||||
raise 'Invalid or expired state'
|
||||
rescue StandardError
|
||||
raise ActionController::BadRequest, 'Invalid account context'
|
||||
end
|
||||
|
||||
def github_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
||||
end
|
||||
|
||||
def github_integration_settings_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
||||
end
|
||||
|
||||
def fallback_redirect_uri
|
||||
github_redirect_uri
|
||||
rescue StandardError
|
||||
# Fallback if no account context available
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -42,7 +42,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
module Github::IntegrationHelper
|
||||
# Generates a signed JWT token for Github integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_github_token(account_id)
|
||||
return if github_client_secret.blank?
|
||||
|
||||
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def github_token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Github JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_github_token(token)
|
||||
return if token.blank? || github_client_secret.blank?
|
||||
|
||||
github_decode_token(token, github_client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def github_client_secret
|
||||
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def github_decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -9,7 +9,6 @@ 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: {
|
||||
@@ -60,10 +59,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -164,116 +159,73 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<span
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 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
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
class="max-w-96 truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
</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">
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
</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,29 +0,0 @@
|
||||
<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>
|
||||
@@ -1,32 +0,0 @@
|
||||
<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>
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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>
|
||||
@@ -1,14 +0,0 @@
|
||||
<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>
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,23 +0,0 @@
|
||||
<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
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,23 +0,0 @@
|
||||
<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
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5 text-n-slate-12 max-w-80">
|
||||
<div class="p-3 rounded-xl bg-n-alpha-2">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.buttons && message.buttons.length > 0"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Button
|
||||
v-for="(button, index) in message.buttons"
|
||||
:key="index"
|
||||
:label="button.text || 'Button'"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,77 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import FileIcon from 'dashboard/components-next/icon/FileIcon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Determine media type based on URL or template data
|
||||
const getMediaType = () => {
|
||||
if (props.message.originalTemplate?.header?.format) {
|
||||
return props.message.originalTemplate.header.format.toLowerCase();
|
||||
}
|
||||
|
||||
const url = props.message.image_url;
|
||||
if (!url) return 'document';
|
||||
|
||||
if (url.includes('.pdf') || url.includes('pdf')) return 'document';
|
||||
if (url.includes('.mp4') || url.includes('.mov') || url.includes('video'))
|
||||
return 'video';
|
||||
return 'image';
|
||||
};
|
||||
|
||||
const mediaType = getMediaType();
|
||||
|
||||
// Get file type from URL for proper icon
|
||||
const fileType = computed(() => {
|
||||
const url = props.message.image_url;
|
||||
if (!url) return 'pdf';
|
||||
|
||||
if (url.includes('.pdf') || url.includes('pdf')) return 'pdf';
|
||||
if (url.includes('.doc')) return 'doc';
|
||||
return 'pdf'; // Default to PDF for documents
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-2.5 p-3 rounded-xl bg-n-alpha-2 text-n-slate-12 max-w-80"
|
||||
>
|
||||
<!-- Image Media -->
|
||||
<img
|
||||
v-if="mediaType === 'image'"
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44 rounded-lg"
|
||||
alt="Template media"
|
||||
/>
|
||||
|
||||
<!-- Video Media -->
|
||||
<div
|
||||
v-else-if="mediaType === 'video'"
|
||||
class="overflow-hidden relative bg-gray-100 rounded-lg"
|
||||
>
|
||||
<video
|
||||
:src="message.image_url"
|
||||
class="object-cover w-full max-h-44"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Document Media -->
|
||||
<div v-else-if="mediaType === 'document'" class="flex items-center">
|
||||
<FileIcon :file-type="fileType" class="text-2xl text-n-slate-12" />
|
||||
</div>
|
||||
|
||||
<!-- Content Text -->
|
||||
<span
|
||||
v-if="message.content"
|
||||
v-dompurify-html="message.content"
|
||||
class="text-sm font-medium prose prose-bubble"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<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
|
||||
v-for="(action, index) in message.actions"
|
||||
:key="index"
|
||||
class="p-3 flex items-center justify-center"
|
||||
>
|
||||
<Button
|
||||
:label="action.title || action.text || 'Button'"
|
||||
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>
|
||||
@@ -1,322 +0,0 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
|
||||
// Sample template data from the JSON files
|
||||
const whatsAppTemplates = {
|
||||
greet: {
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
eventInvitation: {
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
orderConfirmation: {
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/518466505_1106685198073318_3569250580697484416_n.jpg',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
|
||||
discountCoupon: {
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE30OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
|
||||
trainingVideo: {
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by {{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
};
|
||||
|
||||
const twilioTemplates = {
|
||||
greet: {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
|
||||
shoeLaunch: {
|
||||
body: '👟 Introducing our latest release — the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '👟 Introducing our latest release — the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
|
||||
welcomeMessage: {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreview"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<!-- WhatsApp Text Templates -->
|
||||
<Variant title="WhatsApp - Simple Text">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.greet"
|
||||
:variables="{ customer_name: 'John' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Simple Text (No Variables)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.greet"
|
||||
:variables="{}"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Button Templates -->
|
||||
<Variant title="WhatsApp - Call to Action Buttons">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.eventInvitation"
|
||||
:variables="{ event_name: 'F1 Grand Prix', location: 'Dubai' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Media Templates -->
|
||||
<Variant title="WhatsApp - Image Media">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.orderConfirmation"
|
||||
:variables="{ '1': 'Blue Canvas Shoes' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WhatsApp - Video Media">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.trainingVideo"
|
||||
:variables="{ name: 'John', date: 'July 31st' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- WhatsApp Copy Code Templates -->
|
||||
<Variant title="WhatsApp - Copy Code">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates.discountCoupon"
|
||||
:variables="{ discount_percentage: '30' }"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Twilio Templates -->
|
||||
<Variant title="Twilio - Text">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.greet"
|
||||
:variables="{ '1': 'John' }"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Media">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.shoeLaunch"
|
||||
:variables="{ '1': 'Air Jordan', '2': '$150' }"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio - Quick Reply">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates.welcomeMessage"
|
||||
:variables="{}"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,120 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
|
||||
import TextTemplate from 'dashboard/components-next/message/bubbles/Template/Text.vue';
|
||||
import CardTemplate from 'dashboard/components-next/message/bubbles/Template/Card.vue';
|
||||
import DynamicCallToActionTemplate from './DynamicCallToActionTemplate.vue';
|
||||
import DynamicMediaTemplate from './DynamicMediaTemplate.vue';
|
||||
import WhatsAppTextTemplate from './WhatsAppTextTemplate.vue';
|
||||
import DynamicQuickReplyTemplate from './DynamicQuickReplyTemplate.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
variables: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
platform: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => ['whatsapp', 'twilio'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
// Normalize template data and apply variables
|
||||
const processedTemplate = computed(() => {
|
||||
const normalized =
|
||||
props.platform === 'whatsapp'
|
||||
? TemplateNormalizer.normalizeWhatsApp(props.template)
|
||||
: TemplateNormalizer.normalizeTwilio(props.template);
|
||||
|
||||
// Apply variable substitution to content
|
||||
const processText = text => {
|
||||
if (!text) return '';
|
||||
return text.replace(/\{\{([^}]+)\}\}/g, (match, variable) => {
|
||||
const value = props.variables[variable];
|
||||
return value !== undefined && value !== '' ? value : `[${variable}]`;
|
||||
});
|
||||
};
|
||||
|
||||
// Extract content based on platform
|
||||
let content = '';
|
||||
let imageUrl = '';
|
||||
let title = '';
|
||||
let footer = '';
|
||||
|
||||
if (props.platform === 'whatsapp') {
|
||||
// WhatsApp: get text from body component
|
||||
content = normalized.body?.text || '';
|
||||
|
||||
// Get header content for media templates
|
||||
if (normalized.header) {
|
||||
if (
|
||||
normalized.header.format === 'IMAGE' ||
|
||||
normalized.header.format === 'VIDEO' ||
|
||||
normalized.header.format === 'DOCUMENT'
|
||||
) {
|
||||
imageUrl = normalized.header.example?.header_handle?.[0] || '';
|
||||
}
|
||||
if (normalized.header.format === 'TEXT') {
|
||||
title = normalized.header.text || '';
|
||||
}
|
||||
}
|
||||
|
||||
// Get footer content
|
||||
footer = normalized.footer?.text || '';
|
||||
} else {
|
||||
// Twilio: get body directly
|
||||
content = normalized.body || '';
|
||||
|
||||
// Get media URL if available
|
||||
if (normalized.media && normalized.media.length > 0) {
|
||||
imageUrl = normalized.media[0];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
content: processText(content),
|
||||
title: processText(title),
|
||||
footer: processText(footer),
|
||||
image_url: imageUrl,
|
||||
buttons: normalized.buttons || [],
|
||||
actions: normalized.actions || [],
|
||||
};
|
||||
});
|
||||
|
||||
// Component selection based on template type
|
||||
const previewComponent = computed(() => {
|
||||
const type = processedTemplate.value.type;
|
||||
|
||||
const componentMap = {
|
||||
// WhatsApp components
|
||||
'whatsapp-text': WhatsAppTextTemplate,
|
||||
'whatsapp-text-header': WhatsAppTextTemplate,
|
||||
'whatsapp-media-image': DynamicMediaTemplate,
|
||||
'whatsapp-media-video': DynamicMediaTemplate,
|
||||
'whatsapp-media-document': DynamicMediaTemplate,
|
||||
'whatsapp-interactive': DynamicCallToActionTemplate,
|
||||
'whatsapp-copy-code': DynamicCallToActionTemplate,
|
||||
|
||||
// Twilio components
|
||||
'twilio-text': TextTemplate,
|
||||
'twilio-media': DynamicMediaTemplate,
|
||||
'twilio-quick-reply': DynamicQuickReplyTemplate,
|
||||
'twilio-card': CardTemplate,
|
||||
};
|
||||
|
||||
return componentMap[type] || TextTemplate;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="template-preview">
|
||||
<component :is="previewComponent" :message="processedTemplate" />
|
||||
</div>
|
||||
</template>
|
||||
-193
@@ -1,193 +0,0 @@
|
||||
<script setup>
|
||||
import TemplatePreview from './TemplatePreview.vue';
|
||||
import {
|
||||
whatsAppTemplates,
|
||||
getWhatsAppVariables,
|
||||
} from './templates/whatsapp-templates.js';
|
||||
import { twilioTemplates } from './templates/twillio-templates.js';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/TemplatePreviewExamples"
|
||||
:layout="{ type: 'grid', width: 400 }"
|
||||
>
|
||||
<!-- WhatsApp Templates -->
|
||||
<Variant title="WA: Event Invitation (Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[0]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[0])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Purchase Receipt (Document)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[1]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[1])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Discount Coupon (Copy Code)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[2]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[2])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Support Callback (Phone)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[3]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[3])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Training Video (Video)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[4]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[4])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Order Confirmation (Image)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[5]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[5])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Product Launch (Image + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[6]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[6])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Technician Visit (Header + Buttons)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[7]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[7])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Greet (Simple Text)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[8]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[8])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Hello World (Header + Footer)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[9]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[9])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Feedback Request (Button)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[10]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[10])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Address Update (Header)">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[11]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[11])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="WA: Delivery Confirmation">
|
||||
<TemplatePreview
|
||||
:template="whatsAppTemplates[12]"
|
||||
:variables="getWhatsAppVariables(whatsAppTemplates[12])"
|
||||
platform="whatsapp"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<!-- Twilio Templates -->
|
||||
<Variant title="Twilio: Shoe Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[0]"
|
||||
:variables="twilioTemplates[0].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch Custom Price (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[1]"
|
||||
:variables="twilioTemplates[1].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Product Launch (Media)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[2]"
|
||||
:variables="twilioTemplates[2].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Greet (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[3]"
|
||||
:variables="twilioTemplates[3].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Order Status (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[4]"
|
||||
:variables="twilioTemplates[4].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Hello World (Text)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[5]"
|
||||
:variables="twilioTemplates[5].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message New (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[6]"
|
||||
:variables="twilioTemplates[6].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: SaaS WhatsApp Question (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[7]"
|
||||
:variables="twilioTemplates[7].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Twilio: Welcome Message (Quick Reply)">
|
||||
<TemplatePreview
|
||||
:template="twilioTemplates[8]"
|
||||
:variables="twilioTemplates[8].variables"
|
||||
platform="twilio"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<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 flex flex-col gap-2"
|
||||
>
|
||||
<!-- Header Text -->
|
||||
<div v-if="message.title" class="font-bold text-base">
|
||||
{{ message.title }}
|
||||
</div>
|
||||
|
||||
<!-- Body Content -->
|
||||
<div v-if="message.content" class="prose prose-bubble font-medium text-sm">
|
||||
<span v-dompurify-html="message.content" />
|
||||
</div>
|
||||
|
||||
<!-- Footer Text -->
|
||||
<div v-if="message.footer" class="text-xs text-n-slate-11 opacity-70">
|
||||
{{ message.footer }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
// Main Template Preview Component
|
||||
export { default as TemplatePreview } from './TemplatePreview.vue';
|
||||
|
||||
// Core Services
|
||||
export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
|
||||
-185
@@ -1,185 +0,0 @@
|
||||
export const twilioTemplates = [
|
||||
{
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '100$',
|
||||
},
|
||||
content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
|
||||
friendly_name: 'shoe_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'Jordan',
|
||||
2: '400$',
|
||||
},
|
||||
content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
|
||||
friendly_name: 'product_launch_custom_price',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: '=_ Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
types: {
|
||||
'twilio/media': {
|
||||
body: '=_ Introducing our latest release the Nike Air Force! Available now for just $129.99',
|
||||
media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
|
||||
friendly_name: 'product_launch',
|
||||
template_type: 'media',
|
||||
},
|
||||
{
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Hey {{1}}, how may I help you?',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
},
|
||||
content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
|
||||
friendly_name: 'greet',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {
|
||||
1: 'John',
|
||||
3: '12345',
|
||||
},
|
||||
content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
|
||||
friendly_name: 'order_status',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
types: {
|
||||
'twilio/text': {
|
||||
body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en',
|
||||
variables: {},
|
||||
content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
|
||||
friendly_name: 'hello_world',
|
||||
template_type: 'text',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
|
||||
friendly_name: 'welcome_message_new',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
|
||||
actions: [
|
||||
{
|
||||
id: 'Chatwoot Cloud_payload',
|
||||
title: 'Chatwoot Cloud',
|
||||
},
|
||||
{
|
||||
id: 'Self-hosted Chatwoot_payload',
|
||||
title: 'Self-hosted Chatwoot',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
|
||||
friendly_name: 'saas_whatsapp_question',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
{
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
types: {
|
||||
'twilio/quick-reply': {
|
||||
body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
|
||||
actions: [
|
||||
{
|
||||
id: 'Support_payload',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
id: 'Sales_payload',
|
||||
title: 'Sales',
|
||||
},
|
||||
{
|
||||
id: 'Demo_payload',
|
||||
title: 'Demo',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
status: 'approved',
|
||||
category: 'utility',
|
||||
language: 'en_US',
|
||||
variables: {},
|
||||
content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
|
||||
friendly_name: 'welcome_message',
|
||||
template_type: 'quick_reply',
|
||||
},
|
||||
];
|
||||
-408
@@ -1,408 +0,0 @@
|
||||
export const whatsAppTemplates = [
|
||||
{
|
||||
id: '1381151706284063',
|
||||
name: 'event_invitation_static',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'F1',
|
||||
param_name: 'event_name',
|
||||
},
|
||||
{
|
||||
example: 'Dubai',
|
||||
param_name: 'location',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://events.example.com/register',
|
||||
text: 'Visit website',
|
||||
type: 'URL',
|
||||
},
|
||||
{
|
||||
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||
text: 'Get Directions',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '767076159336759',
|
||||
name: 'purchase_receipt',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'DOCUMENT',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['credit', 'CS Mutual', 'receipt']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1469258364071127',
|
||||
name: 'discount_coupon',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: '<� Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: '30',
|
||||
param_name: 'discount_percentage',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Copy offer code',
|
||||
type: 'COPY_CODE',
|
||||
example: ['SAVE1OFF'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1075221534579807',
|
||||
name: 'support_callback',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: '232323',
|
||||
param_name: 'ticket_id',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Call Support',
|
||||
type: 'PHONE_NUMBER',
|
||||
phone_number: '+16506677566',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1023596726651144',
|
||||
name: 'training_video',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'VIDEO',
|
||||
example: {
|
||||
header_handle: [
|
||||
'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'john',
|
||||
param_name: 'name',
|
||||
},
|
||||
{
|
||||
example: 'July 31',
|
||||
param_name: 'date',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1106685194739985',
|
||||
name: 'order_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['blue canvas shoes']],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1242180011253003',
|
||||
name: 'product_launch',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
type: 'HEADER',
|
||||
format: 'IMAGE',
|
||||
example: {
|
||||
header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'blue',
|
||||
param_name: 'color',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1449876326175680',
|
||||
name: 'technician_visit',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Technician visit',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [
|
||||
['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Confirm',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
{
|
||||
text: 'Reschedule',
|
||||
type: 'QUICK_REPLY',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '997298832221901',
|
||||
name: 'greet',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: 'Hey {{customer_name}} how may I help you?',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'John',
|
||||
param_name: 'customer_name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '632315222954611',
|
||||
name: 'hello_world',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Hello World',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||
type: 'BODY',
|
||||
},
|
||||
{
|
||||
text: 'WhatsApp Business Platform sample message',
|
||||
type: 'FOOTER',
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '787864066907971',
|
||||
name: 'feedback_request',
|
||||
status: 'APPROVED',
|
||||
category: 'MARKETING',
|
||||
language: 'en',
|
||||
components: [
|
||||
{
|
||||
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text_named_params: [
|
||||
{
|
||||
example: 'muhsin',
|
||||
param_name: 'name',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'BUTTONS',
|
||||
buttons: [
|
||||
{
|
||||
url: 'https://feedback.example.com/survey',
|
||||
text: 'Leave Feedback',
|
||||
type: 'URL',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sub_category: 'CUSTOM',
|
||||
parameter_format: 'NAMED',
|
||||
},
|
||||
{
|
||||
id: '1938057163677205',
|
||||
name: 'address_update',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: 'Address update',
|
||||
type: 'HEADER',
|
||||
format: 'TEXT',
|
||||
},
|
||||
{
|
||||
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', '123 Main St', 'support@telco.com']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
{
|
||||
id: '1644094842949394',
|
||||
name: 'delivery_confirmation',
|
||||
status: 'APPROVED',
|
||||
category: 'UTILITY',
|
||||
language: 'en_US',
|
||||
components: [
|
||||
{
|
||||
text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
|
||||
type: 'BODY',
|
||||
example: {
|
||||
body_text: [['John', 'Jan 1, 2024']],
|
||||
},
|
||||
},
|
||||
],
|
||||
parameter_format: 'POSITIONAL',
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to get variable values from examples
|
||||
export const getWhatsAppVariables = template => {
|
||||
const variables = {};
|
||||
template.components?.forEach(component => {
|
||||
if (component.example?.body_text_named_params) {
|
||||
component.example.body_text_named_params.forEach(param => {
|
||||
variables[param.param_name] = param.example;
|
||||
});
|
||||
}
|
||||
if (component.example?.body_text) {
|
||||
component.example.body_text[0]?.forEach((value, index) => {
|
||||
variables[(index + 1).toString()] = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
return variables;
|
||||
};
|
||||
@@ -15,6 +15,20 @@
|
||||
},
|
||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||
},
|
||||
"GITHUB": {
|
||||
"TITLE": "Connect Github",
|
||||
"LABEL": "Github Client ID",
|
||||
"PLACEHOLDER": "Enter your Github Client ID",
|
||||
"HELP": "Enter your Github Client ID",
|
||||
"CANCEL": "Cancel",
|
||||
"SUBMIT": "Connect",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"HEADER": "Integrations",
|
||||
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
|
||||
"LEARN_MORE": "Learn more about integrations",
|
||||
@@ -338,6 +352,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
@@ -861,7 +876,6 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -901,10 +915,6 @@
|
||||
"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",
|
||||
@@ -936,9 +946,9 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "Edit",
|
||||
"DELETE_RESPONSE": "Delete"
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
|
||||
@@ -10,7 +10,6 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
export const routes = [
|
||||
@@ -126,19 +125,6 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/responses/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_responses_pending',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||
component: CustomToolsIndex,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
@@ -35,6 +37,7 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
@@ -42,17 +45,54 @@ const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const isStatusFilterOpen = ref(false);
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
const statusOptions = computed(() =>
|
||||
['all', 'pending', 'approved'].map(key => ({
|
||||
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
|
||||
value: key,
|
||||
action: 'filter',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredResponses = computed(() => {
|
||||
return selectedStatus.value === 'pending'
|
||||
? responses.value.filter(r => r.status === 'pending')
|
||||
: responses.value;
|
||||
});
|
||||
|
||||
const selectedStatusLabel = computed(() => {
|
||||
const status = statusOptions.value.find(
|
||||
option => option.value === selectedStatus.value
|
||||
);
|
||||
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
|
||||
selected: status ? status.label : '',
|
||||
});
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
@@ -65,7 +105,9 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
@@ -73,6 +115,9 @@ const handleAction = ({ action, id }) => {
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,8 +136,10 @@ const handleCreateClose = () => {
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
|
||||
const filterParams = { page };
|
||||
if (selectedStatus.value !== 'all') {
|
||||
filterParams.status = selectedStatus.value;
|
||||
}
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
@@ -108,7 +155,7 @@ const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = responses.value?.length || 0;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
@@ -121,13 +168,13 @@ const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(responses.value.map(r => r.id))
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = responses.value?.length || 0;
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
@@ -144,7 +191,7 @@ const handleCardSelect = id => {
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = responses.value?.length === 0;
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
@@ -161,6 +208,22 @@ const fetchResponseAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
// Store current selection state before fetching new page
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
@@ -175,7 +238,7 @@ const onPageChange = page => {
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
@@ -184,6 +247,12 @@ const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = ({ value }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
@@ -193,14 +262,9 @@ const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_responses_pending' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
store.dispatch('captainResponses/fetchPendingCount');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -212,8 +276,8 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!responses.length"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
@@ -251,7 +315,25 @@ onMounted(() => {
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
@@ -262,7 +344,6 @@ onMounted(() => {
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
@@ -299,6 +380,15 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
@@ -316,19 +406,10 @@ onMounted(() => {
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
color="blue"
|
||||
class="mb-4"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in responses"
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
@@ -341,7 +422,6 @@ onMounted(() => {
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
:show-actions="false"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const backUrl = computed(() =>
|
||||
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
|
||||
);
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||
};
|
||||
});
|
||||
|
||||
const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (wasAllPageSelected || hadPartialSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
}, 500);
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'github');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
return integration.value.action;
|
||||
});
|
||||
|
||||
const initializeGithubIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'github');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeGithubIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
|
||||
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+11
-1
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
|
||||
import Github from './Github.vue';
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -100,6 +100,16 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'github',
|
||||
name: 'settings_integrations_github',
|
||||
component: Github,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ error: route.query.error }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { TemplateTypeDetector } from './TemplateTypeDetector';
|
||||
|
||||
/**
|
||||
* TemplateNormalizer - Convert platform-specific formats to unified structure
|
||||
*/
|
||||
export class TemplateNormalizer {
|
||||
/**
|
||||
* Normalize WhatsApp template to unified format
|
||||
* @param {Object} template - WhatsApp template object
|
||||
* @returns {Object} - Normalized template
|
||||
*/
|
||||
static normalizeWhatsApp(template) {
|
||||
const components = template.components || [];
|
||||
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
platform: 'whatsapp',
|
||||
type: TemplateTypeDetector.detectWhatsAppType(template),
|
||||
parameterFormat: template.parameter_format || 'POSITIONAL',
|
||||
header: components.find(c => c.type === 'HEADER'),
|
||||
body: components.find(c => c.type === 'BODY'),
|
||||
footer: components.find(c => c.type === 'FOOTER'),
|
||||
buttons: components.find(c => c.type === 'BUTTONS')?.buttons || [],
|
||||
variables: this.extractWhatsAppVariables(template),
|
||||
category: template.category,
|
||||
language: template.language,
|
||||
originalTemplate: template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Twilio template to unified format
|
||||
* @param {Object} template - Twilio template object
|
||||
* @returns {Object} - Normalized template
|
||||
*/
|
||||
static normalizeTwilio(template) {
|
||||
// Convert template_type to match the keys used in the types object
|
||||
const normalizedType = template.template_type.replace('_', '-');
|
||||
const lookupKey = `twilio/${normalizedType}`;
|
||||
const typeData = template.types?.[lookupKey] || {};
|
||||
|
||||
return {
|
||||
contentSid: template.content_sid,
|
||||
name: template.friendly_name,
|
||||
platform: 'twilio',
|
||||
type: TemplateTypeDetector.detectTwilioType(template),
|
||||
body: template.body || typeData.body,
|
||||
media: typeData.media || [],
|
||||
actions: typeData.actions || [],
|
||||
variables: template.variables || {},
|
||||
category: template.category || 'utility',
|
||||
language: template.language || 'en',
|
||||
originalTemplate: template,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variables from WhatsApp template
|
||||
* @param {Object} template - WhatsApp template object
|
||||
* @returns {Object} - Variables object with example values
|
||||
*/
|
||||
static extractWhatsAppVariables(template) {
|
||||
const variables = {};
|
||||
const components = template.components || [];
|
||||
|
||||
components.forEach(component => {
|
||||
// Extract from text content
|
||||
if (component.text) {
|
||||
const matches = component.text.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
|
||||
if (template.parameter_format === 'NAMED') {
|
||||
// Named parameters: {{customer_name}}
|
||||
const example =
|
||||
component.example?.body_text_named_params?.find(
|
||||
p => p.param_name === variable
|
||||
)?.example || '';
|
||||
variables[variable] = example;
|
||||
} else {
|
||||
// Positional parameters: {{1}}, {{2}}
|
||||
const position = parseInt(variable, 10) - 1;
|
||||
const example = component.example?.body_text?.[0]?.[position] || '';
|
||||
variables[variable] = example;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract from button URLs
|
||||
if (component.buttons) {
|
||||
component.buttons.forEach(button => {
|
||||
if (button.url) {
|
||||
const matches = button.url.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
const example = button.example?.[0] || '';
|
||||
variables[variable] = example;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract variables from Twilio template
|
||||
* @param {Object} template - Twilio template object
|
||||
* @returns {Object} - Variables object with example values
|
||||
*/
|
||||
static extractTwilioVariables(template) {
|
||||
const variables = {};
|
||||
|
||||
// Extract from body text
|
||||
if (template.body) {
|
||||
const matches = template.body.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
variables[variable] = template.variables?.[variable] || '';
|
||||
});
|
||||
}
|
||||
|
||||
// Extract from media URLs
|
||||
const typeData = template.types?.[`twilio/${template.template_type}`] || {};
|
||||
if (typeData.media) {
|
||||
typeData.media.forEach(mediaUrl => {
|
||||
const matches = mediaUrl.match(/\{\{([^}]+)\}\}/g) || [];
|
||||
matches.forEach(match => {
|
||||
const variable = match.replace(/[{}]/g, '');
|
||||
variables[variable] = template.variables?.[variable] || '';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize template from any platform
|
||||
* @param {Object} template - Template object
|
||||
* @param {string} platform - Platform identifier ('whatsapp' | 'twilio')
|
||||
* @returns {Object} - Normalized template
|
||||
*/
|
||||
static normalize(template, platform) {
|
||||
switch (platform) {
|
||||
case 'whatsapp':
|
||||
return this.normalizeWhatsApp(template);
|
||||
case 'twilio':
|
||||
return this.normalizeTwilio(template);
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* TemplateTypeDetector - Unified service to identify template types across platforms
|
||||
*/
|
||||
export class TemplateTypeDetector {
|
||||
/**
|
||||
* Detect WhatsApp template type based on components
|
||||
* @param {Object} template - WhatsApp template object
|
||||
* @returns {string} - Template type identifier
|
||||
*/
|
||||
static detectWhatsAppType(template) {
|
||||
const components = template.components || [];
|
||||
const hasHeader = components.find(c => c.type === 'HEADER');
|
||||
const hasButtons = components.find(c => c.type === 'BUTTONS');
|
||||
|
||||
// Media templates (priority: header media)
|
||||
if (hasHeader?.format === 'IMAGE') return 'whatsapp-media-image';
|
||||
if (hasHeader?.format === 'VIDEO') return 'whatsapp-media-video';
|
||||
if (hasHeader?.format === 'DOCUMENT') return 'whatsapp-media-document';
|
||||
|
||||
// Interactive templates (priority: special buttons)
|
||||
if (hasButtons) {
|
||||
const copyCodeButton = hasButtons.buttons?.find(
|
||||
b => b.type === 'COPY_CODE'
|
||||
);
|
||||
if (copyCodeButton) return 'whatsapp-copy-code';
|
||||
return 'whatsapp-interactive';
|
||||
}
|
||||
|
||||
// Text templates
|
||||
if (hasHeader?.format === 'TEXT') return 'whatsapp-text-header';
|
||||
return 'whatsapp-text';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect Twilio template type
|
||||
* @param {Object} template - Twilio template object
|
||||
* @returns {string} - Template type identifier
|
||||
*/
|
||||
static detectTwilioType(template) {
|
||||
switch (template.template_type) {
|
||||
case 'media':
|
||||
return 'twilio-media';
|
||||
case 'quick_reply':
|
||||
return 'twilio-quick-reply';
|
||||
default:
|
||||
return 'twilio-text';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported template types
|
||||
* @returns {Array} - Array of supported template types
|
||||
*/
|
||||
static getSupportedTypes() {
|
||||
return [
|
||||
// WhatsApp types
|
||||
'whatsapp-text',
|
||||
'whatsapp-text-header',
|
||||
'whatsapp-media-image',
|
||||
'whatsapp-media-video',
|
||||
'whatsapp-media-document',
|
||||
'whatsapp-interactive',
|
||||
'whatsapp-copy-code',
|
||||
|
||||
// Twilio types
|
||||
'twilio-text',
|
||||
'twilio-media',
|
||||
'twilio-quick-reply',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,9 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
getters: {
|
||||
getPendingCount: state => state.meta.pendingCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_PENDING_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
pendingCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
@@ -41,18 +28,5 @@ export default createStore({
|
||||
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||
try {
|
||||
const response = await CaptainResponseAPI.get({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
assistantId,
|
||||
});
|
||||
const count = response.data?.meta?.total_count || 0;
|
||||
commit(SET_PENDING_COUNT, count);
|
||||
} catch (error) {
|
||||
commit(SET_PENDING_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,7 +49,6 @@ export const createMutations = mutationTypes => ({
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
@@ -70,7 +69,7 @@ export const createCrudActions = (API, mutationTypes) => ({
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const { name, API, actions, getters } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
@@ -82,10 +81,7 @@ export const createStore = options => {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
mutations: createMutations(mutationTypes),
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
|
||||
@@ -78,11 +78,6 @@ export const getters = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out authentication templates
|
||||
if (template.category === 'AUTHENTICATION') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
|
||||
@@ -265,39 +265,6 @@ describe('#getters', () => {
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('filters out authentication templates', () => {
|
||||
const authenticationTemplates = [
|
||||
{
|
||||
name: 'auth_template',
|
||||
status: 'approved',
|
||||
category: 'AUTHENTICATION',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Your verification code is {{1}}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: authenticationTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('returns valid templates from fixture data', () => {
|
||||
const state = {
|
||||
records: [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Integrations::App
|
||||
include Linear::IntegrationHelper
|
||||
include Github::IntegrationHelper
|
||||
attr_accessor :params
|
||||
|
||||
def initialize(params)
|
||||
@@ -30,10 +31,15 @@ class Integrations::App
|
||||
params[:fields]
|
||||
end
|
||||
|
||||
# There is no way to get the account_id from the linear callback
|
||||
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
||||
# There is no way to get the account_id from the linear/github callback
|
||||
# so we are using the generate token method to generate a token and encode it in the state parameter
|
||||
def encode_state
|
||||
generate_linear_token(Current.account.id)
|
||||
case params[:id]
|
||||
when 'linear'
|
||||
generate_linear_token(Current.account.id)
|
||||
when 'github'
|
||||
generate_github_token(Current.account.id)
|
||||
end
|
||||
end
|
||||
|
||||
def action
|
||||
@@ -43,6 +49,8 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'github'
|
||||
build_github_action
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
@@ -54,6 +62,8 @@ class Integrations::App
|
||||
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'github'
|
||||
github_enabled?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
@@ -78,6 +88,17 @@ class Integrations::App
|
||||
].join('&')
|
||||
end
|
||||
|
||||
def build_github_action
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# For GitHub Apps, redirect to installation page
|
||||
# The standard /installations/new URL should show org/user selection if the app is properly configured
|
||||
# Include state parameter with signed account ID for account context
|
||||
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
|
||||
state = Current.account.to_signed_global_id(expires_in: 1.hour)
|
||||
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
|
||||
end
|
||||
|
||||
def enabled?(account)
|
||||
case params[:id]
|
||||
when 'webhook'
|
||||
@@ -101,6 +122,10 @@ class Integrations::App
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
|
||||
end
|
||||
|
||||
def self.github_integration_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
|
||||
end
|
||||
|
||||
class << self
|
||||
def apps
|
||||
Hashie::Mash.new(APPS_CONFIG)
|
||||
@@ -119,6 +144,10 @@ class Integrations::App
|
||||
|
||||
private
|
||||
|
||||
def github_enabled?
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
|
||||
end
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
class Github::GithubService
|
||||
attr_reader :hook
|
||||
|
||||
def initialize(hook:)
|
||||
@hook = hook
|
||||
end
|
||||
|
||||
def repositories
|
||||
client.repositories(nil, type: 'all')
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def assignees(repo_full_name)
|
||||
client.repository_assignees(repo_full_name)
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def search_repositories(query)
|
||||
return repositories if query.blank?
|
||||
|
||||
# Get all user's repositories first
|
||||
user_repos = repositories
|
||||
|
||||
# Filter repositories by query string (case-insensitive)
|
||||
user_repos.select do |repo|
|
||||
repo.full_name.downcase.include?(query.downcase) ||
|
||||
(repo.description&.downcase&.include?(query.downcase))
|
||||
end
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= Octokit::Client.new(
|
||||
access_token: hook.access_token,
|
||||
auto_paginate: true,
|
||||
per_page: 100
|
||||
)
|
||||
end
|
||||
|
||||
def raise_api_error(error)
|
||||
case error.response_status
|
||||
when 401
|
||||
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
|
||||
when 403
|
||||
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
|
||||
when 404
|
||||
raise CustomExceptions::Base.new('GitHub resource not found', 404)
|
||||
else
|
||||
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -169,7 +169,11 @@
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="icon-github" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
@@ -176,6 +176,9 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: github_integration
|
||||
display_name: Github Integration
|
||||
enabled: false
|
||||
- name: captain_integration_v2
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
|
||||
@@ -451,3 +451,22 @@
|
||||
locked: false
|
||||
description: 'Token expiry in days'
|
||||
## ------ End of Customizations for Customers ------ ##
|
||||
|
||||
# ------- Github Integration Config ------- #
|
||||
- name: GITHUB_CLIENT_ID
|
||||
display_title: 'Github Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client ID'
|
||||
- name: GITHUB_CLIENT_SECRET
|
||||
display_title: 'Github Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client secret'
|
||||
type: secret
|
||||
- name: GITHUB_APP_NAME
|
||||
display_title: 'Github App Name'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
|
||||
## ------ End of Github Integration Config ------- #
|
||||
|
||||
@@ -284,3 +284,11 @@ leadsquared:
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
github:
|
||||
id: github
|
||||
logo: github.png
|
||||
i18n_key: github
|
||||
hook_type: account
|
||||
action: https://github.com/login/oauth/authorize
|
||||
allow_multiple_hooks: false
|
||||
|
||||
@@ -312,6 +312,10 @@ en:
|
||||
name: 'LeadSquared'
|
||||
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
|
||||
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
|
||||
github:
|
||||
name: 'GitHub'
|
||||
short_description: 'Create and manage GitHub issues directly from conversations.'
|
||||
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
|
||||
captain:
|
||||
copilot_message_required: Message is required
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
|
||||
@@ -302,6 +302,14 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resource :github, controller: 'github', only: [] do
|
||||
collection do
|
||||
delete :destroy
|
||||
get :repositories
|
||||
get :search_repositories
|
||||
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -549,6 +557,10 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :github do
|
||||
get :callback, to: 'callbacks#show'
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
|
||||
@@ -5,13 +5,11 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
metadata = payload[:metadata]
|
||||
|
||||
canonical_url = normalize_link(metadata['url'])
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: canonical_url
|
||||
external_link: metadata['url']
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: canonical_url,
|
||||
content: payload[:markdown],
|
||||
name: metadata['title'],
|
||||
status: :available
|
||||
@@ -19,10 +17,4 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse FireCrawl data: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_url)
|
||||
raw_url.to_s.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
page_title = crawler.page_title || ''
|
||||
content = crawler.body_text_content || ''
|
||||
|
||||
normalized_link = normalize_link(page_link)
|
||||
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: page_link
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: page_title[0..254], content: content[0..14_999], status: :available
|
||||
)
|
||||
rescue StandardError => e
|
||||
@@ -28,10 +28,6 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_link)
|
||||
raw_link.to_s.delete_suffix('/')
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
limits[:current_available].negative? || limits[:current_available].zero?
|
||||
|
||||
@@ -37,7 +37,6 @@ class Captain::Document < ApplicationRecord
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
before_validation :normalize_external_link
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -48,7 +47,7 @@ class Captain::Document < ApplicationRecord
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
after_commit :enqueue_response_builder_job
|
||||
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
@@ -95,18 +94,15 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return unless should_enqueue_response_builder?
|
||||
return if status != 'available'
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
return false if destroyed?
|
||||
return false unless available?
|
||||
|
||||
return saved_change_to_status? if pdf_document?
|
||||
|
||||
(saved_change_to_status? || saved_change_to_content?) && content.present?
|
||||
# Only enqueue when status changes to available
|
||||
# Avoid re-enqueueing when metadata is updated by the job itself
|
||||
saved_change_to_status? && status == 'available'
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
@@ -144,11 +140,4 @@ class Captain::Document < ApplicationRecord
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
|
||||
def normalize_external_link
|
||||
return if external_link.blank?
|
||||
return if pdf_document?
|
||||
|
||||
self.external_link = external_link.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,165 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Github::CallbacksController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
|
||||
|
||||
before do
|
||||
# Clear any existing GitHub hooks for the account
|
||||
account.hooks.where(app_id: 'github').destroy_all
|
||||
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
|
||||
end
|
||||
|
||||
describe 'route accessibility' do
|
||||
it 'is accessible via the github callback route' do
|
||||
expect(get: '/github/callback').to route_to(
|
||||
controller: 'github/callbacks',
|
||||
action: 'show'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'helper methods' do
|
||||
describe '#account' do
|
||||
it 'resolves account from valid signed global id' do
|
||||
controller.params = { state: signed_account_id }
|
||||
expect(controller.send(:account)).to eq(account)
|
||||
end
|
||||
|
||||
it 'raises error for missing state' do
|
||||
controller.params = {}
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
|
||||
it 'raises error for invalid state' do
|
||||
controller.params = { state: 'invalid_state' }
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#oauth_client' do
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
oauth_client = controller.send(:oauth_client)
|
||||
expect(oauth_client).to be_a(OAuth2::Client)
|
||||
expect(oauth_client.id).to eq('test_client_id')
|
||||
expect(oauth_client.secret).to eq('test_client_secret')
|
||||
expect(oauth_client.site).to eq('https://github.com')
|
||||
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
|
||||
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#github_redirect_uri' do
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
end
|
||||
|
||||
it 'generates correct GitHub redirect URI' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:github_redirect_uri)
|
||||
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fallback_redirect_uri' do
|
||||
it 'generates fallback redirect URI when account is unavailable' do
|
||||
allow(controller).to receive(:account).and_raise(StandardError)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:fallback_redirect_uri)
|
||||
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_hook_settings' do
|
||||
let(:mock_parsed_body) do
|
||||
{
|
||||
'access_token' => 'test_token',
|
||||
'token_type' => 'bearer',
|
||||
'scope' => 'repo,read:org'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
|
||||
end
|
||||
|
||||
it 'builds hook settings with installation_id' do
|
||||
settings = controller.send(:build_hook_settings, '12345')
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds hook settings without installation_id when nil' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org'
|
||||
)
|
||||
expect(settings).not_to have_key(:installation_id)
|
||||
end
|
||||
|
||||
it 'removes nil installation_id values' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings.keys).not_to include(:installation_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_integration_hook' do
|
||||
let(:settings) do
|
||||
{
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:parsed_body).and_return({
|
||||
'access_token' => 'test_token'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates integration hook with correct attributes' do
|
||||
hook = controller.send(:create_integration_hook, settings)
|
||||
expect(hook.access_token).to eq('test_token')
|
||||
expect(hook.status).to eq('enabled')
|
||||
expect(hook.app_id).to eq('github')
|
||||
expect(hook.settings).to eq(settings.stringify_keys)
|
||||
expect(hook.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_oauth_url' do
|
||||
it 'builds OAuth URL with installation context' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
url = controller.send(:build_oauth_url, '12345')
|
||||
expect(url).to include('github?setup_action=install&installation_id=12345')
|
||||
expect(controller.session[:github_installation_id]).to eq('12345')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#base_url' do
|
||||
it 'returns FRONTEND_URL when set' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns default localhost when FRONTEND_URL not set' do
|
||||
with_modified_env FRONTEND_URL: nil do
|
||||
expect(controller.send(:base_url)).to eq('http://localhost:3000')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
external_link: payload[:metadata]['url'],
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
external_link: payload[:metadata]['url'],
|
||||
content: 'old content',
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
@@ -42,9 +42,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:page_link) { 'https://example.com/page/' }
|
||||
let(:page_link) { 'https://example.com/page' }
|
||||
let(:page_title) { 'Example Page Title' }
|
||||
let(:content) { 'Some page content here' }
|
||||
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.external_link).to eq(page_link)
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
it 'updates existing document if one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
external_link: 'https://example.com/page',
|
||||
external_link: page_link,
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
|
||||
@@ -4,17 +4,6 @@ RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'URL normalization' do
|
||||
it 'removes a trailing slash before validation' do
|
||||
document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/path/')
|
||||
|
||||
expect(document.external_link).to eq('https://example.com/path')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
@@ -93,161 +82,4 @@ RSpec.describe Captain::Document, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response builder job callback' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
describe 'non-PDF documents' do
|
||||
it 'enqueues when created with available status and content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when created available without content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available with existing content' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when status transitions to available without content' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content is populated on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: nil
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Fresh content from crawl')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content changes on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Updated crawl content')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content is cleared on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue for metadata-only updates' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated Again' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue while document remains in progress' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF documents' do
|
||||
def build_pdf_document(status:, content:)
|
||||
build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: status,
|
||||
content: content
|
||||
).tap do |doc|
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'sample.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues when created available without content' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
|
||||
expect do
|
||||
document.save!
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available' do
|
||||
document = build_pdf_document(status: :in_progress, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content updates without status change' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Extracted PDF text')
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue when the document is destroyed' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.destroy!
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,6 +38,25 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns the GitHub App installation URL with state parameter' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
|
||||
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
|
||||
expect(app.action).to include('state=')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses default app name when GITHUB_APP_NAME is not set' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
|
||||
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
@@ -87,6 +106,34 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns false if github credentials are not present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_id is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_secret is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns true if both client_id and client_secret are present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when other apps are queried' do
|
||||
let(:app_name) { 'webhook' }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user