Merge branch 'develop' into feat/github-integration
This commit is contained in:
+1
-1
@@ -283,7 +283,7 @@ Rails/RedundantActiveRecordAllMethod:
|
||||
Enabled: false
|
||||
|
||||
Layout/TrailingEmptyLines:
|
||||
Enabled: false
|
||||
Enabled: true
|
||||
|
||||
Style/SafeNavigationChainLength:
|
||||
Enabled: false
|
||||
|
||||
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def campaign_params
|
||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {})
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,4 +11,4 @@ class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::Bas
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,4 +18,4 @@ class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::O
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
before_action :validate_feature_enabled!
|
||||
|
||||
# POST /api/v1/accounts/:account_id/whatsapp/authorization
|
||||
# Handles the embedded signup callback data from the Facebook SDK
|
||||
def create
|
||||
validate_embedded_signup_params!
|
||||
channel = process_embedded_signup
|
||||
render_success_response(channel.inbox)
|
||||
rescue StandardError => e
|
||||
render_error_response(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_embedded_signup
|
||||
service = Whatsapp::EmbeddedSignupService.new(
|
||||
account: Current.account,
|
||||
code: params[:code],
|
||||
business_id: params[:business_id],
|
||||
waba_id: params[:waba_id],
|
||||
phone_number_id: params[:phone_number_id]
|
||||
)
|
||||
service.perform
|
||||
end
|
||||
|
||||
def render_success_response(inbox)
|
||||
render json: {
|
||||
success: true,
|
||||
id: inbox.id,
|
||||
name: inbox.name,
|
||||
channel_type: 'whatsapp'
|
||||
}
|
||||
end
|
||||
|
||||
def render_error_response(error)
|
||||
Rails.logger.error "[WHATSAPP AUTHORIZATION] Embedded signup error: #{error.message}"
|
||||
Rails.logger.error error.backtrace.join("\n")
|
||||
render json: {
|
||||
success: false,
|
||||
error: error.message
|
||||
}, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def validate_feature_enabled!
|
||||
return if Current.account.feature_whatsapp_embedded_signup?
|
||||
|
||||
render json: {
|
||||
success: false,
|
||||
error: 'WhatsApp embedded signup is not enabled for this account'
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def validate_embedded_signup_params!
|
||||
missing_params = []
|
||||
missing_params << 'code' if params[:code].blank?
|
||||
missing_params << 'business_id' if params[:business_id].blank?
|
||||
missing_params << 'waba_id' if params[:waba_id].blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -67,6 +67,8 @@ class DashboardController < ActionController::Base
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
INSTAGRAM_APP_ID: GlobalConfigService.load('INSTAGRAM_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
|
||||
WHATSAPP_APP_ID: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''),
|
||||
IS_ENTERPRISE: ChatwootApp.enterprise?,
|
||||
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
|
||||
GIT_SHA: GIT_HASH
|
||||
|
||||
@@ -33,4 +33,4 @@ class Notion::CallbacksController < OauthCallbackController
|
||||
def notion_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
class SuperAdmin::ApplicationController < Administrate::ApplicationController
|
||||
include ActionView::Helpers::TagHelper
|
||||
include ActionView::Context
|
||||
include SuperAdmin::NavigationHelper
|
||||
|
||||
helper_method :render_vue_component
|
||||
helper_method :render_vue_component, :settings_open?, :settings_pages
|
||||
# authenticiation done via devise : SuperAdmin Model
|
||||
before_action :authenticate_super_admin!
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
# TODO: Move this values to features.yml itself
|
||||
# No need to replicate the same values in two places
|
||||
|
||||
# ------- Premium Features ------- #
|
||||
captain:
|
||||
name: 'Captain'
|
||||
description: 'Enable AI-powered conversations with your customers.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-captain'
|
||||
config_key: 'captain'
|
||||
enterprise: true
|
||||
custom_branding:
|
||||
name: 'Custom Branding'
|
||||
description: 'Apply your own branding to this installation.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-paint-brush-line'
|
||||
config_key: 'custom_branding'
|
||||
enterprise: true
|
||||
agent_capacity:
|
||||
name: 'Agent Capacity'
|
||||
description: 'Set limits to auto-assigning conversations to your agents.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-hourglass-line'
|
||||
enterprise: true
|
||||
audit_logs:
|
||||
name: 'Audit Logs'
|
||||
description: 'Track and trace account activities with ease with detailed audit logs.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-menu-search-line'
|
||||
enterprise: true
|
||||
disable_branding:
|
||||
name: 'Disable Branding'
|
||||
description: 'Disable branding on live-chat widget and external emails.'
|
||||
enabled: <%= (ChatwootHub.pricing_plan != 'community') %>
|
||||
icon: 'icon-sailbot-fill'
|
||||
enterprise: true
|
||||
|
||||
# ------- Product Features ------- #
|
||||
help_center:
|
||||
name: 'Help Center'
|
||||
description: 'Allow agents to create help center articles and publish them in a portal.'
|
||||
enabled: true
|
||||
icon: 'icon-book-2-line'
|
||||
|
||||
# ------- Communication Channels ------- #
|
||||
live_chat:
|
||||
name: 'Live Chat'
|
||||
description: 'Improve your customer experience using a live chat on your website.'
|
||||
enabled: true
|
||||
icon: 'icon-chat-smile-3-line'
|
||||
email:
|
||||
name: 'Email'
|
||||
description: 'Manage your email customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-mail-send-fill'
|
||||
config_key: 'email'
|
||||
sms:
|
||||
name: 'SMS'
|
||||
description: 'Manage your SMS customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-message-line'
|
||||
messenger:
|
||||
name: 'Messenger'
|
||||
description: 'Stay connected with your customers on Facebook & Instagram.'
|
||||
enabled: true
|
||||
icon: 'icon-messenger-line'
|
||||
config_key: 'facebook'
|
||||
instagram:
|
||||
name: 'Instagram'
|
||||
description: 'Stay connected with your customers on Instagram'
|
||||
enabled: true
|
||||
icon: 'icon-instagram'
|
||||
config_key: 'instagram'
|
||||
whatsapp:
|
||||
name: 'WhatsApp'
|
||||
description: 'Manage your WhatsApp business interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
telegram:
|
||||
name: 'Telegram'
|
||||
description: 'Manage your Telegram customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-telegram-line'
|
||||
line:
|
||||
name: 'Line'
|
||||
description: 'Manage your Line customer interactions from Chatwoot.'
|
||||
enabled: true
|
||||
icon: 'icon-line-line'
|
||||
|
||||
# ------- OAuth & Authentication ------- #
|
||||
google:
|
||||
name: 'Google'
|
||||
description: 'Configuration for setting up Google OAuth Integration'
|
||||
enabled: true
|
||||
icon: 'icon-google'
|
||||
config_key: 'google'
|
||||
microsoft:
|
||||
name: 'Microsoft'
|
||||
description: 'Configuration for setting up Microsoft Email'
|
||||
enabled: true
|
||||
icon: 'icon-microsoft'
|
||||
config_key: 'microsoft'
|
||||
|
||||
# ------- Third-party Integrations ------- #
|
||||
linear:
|
||||
name: 'Linear'
|
||||
description: 'Configuration for setting up Linear Integration'
|
||||
enabled: true
|
||||
icon: 'icon-linear'
|
||||
config_key: 'linear'
|
||||
notion:
|
||||
name: 'Notion'
|
||||
description: 'Configuration for setting up Notion Integration'
|
||||
enabled: true
|
||||
icon: 'icon-notion'
|
||||
config_key: 'notion'
|
||||
slack:
|
||||
name: 'Slack'
|
||||
description: 'Configuration for setting up Slack Integration'
|
||||
enabled: true
|
||||
icon: 'icon-slack'
|
||||
config_key: 'slack'
|
||||
whatsapp_embedded:
|
||||
name: 'WhatsApp Embedded'
|
||||
description: 'Configuration for setting up WhatsApp Embedded Integration'
|
||||
enabled: true
|
||||
icon: 'icon-whatsapp-line'
|
||||
config_key: 'whatsapp_embedded'
|
||||
shopify:
|
||||
name: 'Shopify'
|
||||
description: 'Configuration for setting up Shopify Integration'
|
||||
enabled: true
|
||||
icon: 'icon-shopify'
|
||||
config_key: 'shopify'
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module SuperAdmin::FeaturesHelper
|
||||
def self.available_features
|
||||
YAML.load(ERB.new(Rails.root.join('enterprise/app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
||||
YAML.load(ERB.new(Rails.root.join('app/helpers/super_admin/features.yml').read).result).with_indifferent_access
|
||||
end
|
||||
|
||||
def self.plan_details
|
||||
@@ -0,0 +1,16 @@
|
||||
module SuperAdmin::NavigationHelper
|
||||
def settings_open?
|
||||
params[:controller].in? %w[super_admin/settings super_admin/app_configs]
|
||||
end
|
||||
|
||||
def settings_pages
|
||||
features = SuperAdmin::FeaturesHelper.available_features.select do |_feature, attrs|
|
||||
attrs['config_key'].present? && attrs['enabled']
|
||||
end
|
||||
|
||||
# Add general at the beginning
|
||||
general_feature = [['general', { 'config_key' => 'general', 'name' => 'General' }]]
|
||||
|
||||
general_feature + features.to_a
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class WhatsappChannel extends ApiClient {
|
||||
constructor() {
|
||||
super('whatsapp', { accountScoped: true });
|
||||
}
|
||||
|
||||
createEmbeddedSignup(params) {
|
||||
return axios.post(`${this.baseUrl()}/whatsapp/authorization`, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default new WhatsappChannel();
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<CampaignCard
|
||||
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', campaignDetails);
|
||||
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.ONE_OFF,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
<script setup>
|
||||
import { reactive, computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
inboxId: null,
|
||||
templateId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const processedParams = ref({});
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
templateId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
if (!state.inboxId) return [];
|
||||
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||
return templates.map(template => {
|
||||
// Create a more user-friendly label from template name
|
||||
const friendlyName = template.name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
return {
|
||||
value: template.id,
|
||||
label: `${friendlyName} (${template.language || 'en'})`,
|
||||
template: template,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const selectedTemplate = computed(() => {
|
||||
if (!state.templateId) return null;
|
||||
return templateOptions.value.find(option => option.value === state.templateId)
|
||||
?.template;
|
||||
});
|
||||
|
||||
const templateString = computed(() => {
|
||||
if (!selectedTemplate.value) return '';
|
||||
try {
|
||||
return (
|
||||
selectedTemplate.value.components?.find(
|
||||
component => component.type === 'BODY'
|
||||
)?.text || ''
|
||||
);
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const processedString = computed(() => {
|
||||
if (!templateString.value) return '';
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
return processedParams.value[variable] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
template: getErrorMessage('templateId', 'TEMPLATE'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const hasRequiredTemplateParams = computed(() => {
|
||||
const params = Object.values(processedParams.value);
|
||||
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
() => v$.value.$invalid || !hasRequiredTemplateParams.value
|
||||
);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
const resetState = () => {
|
||||
Object.assign(state, initialState);
|
||||
processedParams.value = {};
|
||||
v$.value.$reset();
|
||||
};
|
||||
|
||||
const handleCancel = () => emit('cancel');
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) {
|
||||
processedParams.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = processedParams.value[variable] || '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const prepareCampaignDetails = () => {
|
||||
// Find the selected template to get its content
|
||||
const currentTemplate = selectedTemplate.value;
|
||||
|
||||
// Extract template content - this should be the template message body
|
||||
const templateContent = templateString.value;
|
||||
|
||||
// Prepare template_params object with the same structure as used in contacts
|
||||
const templateParams = {
|
||||
name: currentTemplate?.name || '',
|
||||
namespace: currentTemplate?.namespace || '',
|
||||
category: currentTemplate?.category || 'UTILITY',
|
||||
language: currentTemplate?.language || 'en_US',
|
||||
processed_params: processedParams.value,
|
||||
};
|
||||
|
||||
return {
|
||||
title: state.title,
|
||||
message: templateContent,
|
||||
template_params: templateParams,
|
||||
inbox_id: state.inboxId,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
|
||||
// Reset template selection when inbox changes
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
() => {
|
||||
state.templateId = null;
|
||||
processedParams.value = {};
|
||||
}
|
||||
);
|
||||
|
||||
// Generate variables when template changes
|
||||
watch(
|
||||
() => state.templateId,
|
||||
() => {
|
||||
generateVariables();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="template"
|
||||
v-model="state.templateId"
|
||||
:options="templateOptions"
|
||||
:has-error="!!formErrors.template"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
|
||||
:message="formErrors.template"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Preview -->
|
||||
<div
|
||||
v-if="selectedTemplate"
|
||||
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ selectedTemplate.name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
|
||||
{{ selectedTemplate.language || 'en' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md bg-n-alpha-black3">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ processedString }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
|
||||
{{ selectedTemplate.category || 'UTILITY' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Variables -->
|
||||
<div
|
||||
v-if="Object.keys(processedParams).length > 0"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(value, key) in processedParams"
|
||||
:key="key"
|
||||
class="flex gap-2 items-center"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[key]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
|
||||
variable: key,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -52,9 +52,9 @@ const handleBreadcrumbClick = item => {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="my-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
class="mt-4 px-10 flex flex-col w-full h-screen overflow-y-auto bg-n-background"
|
||||
>
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full">
|
||||
<div class="max-w-[60rem] mx-auto flex flex-col w-full h-full mb-4">
|
||||
<header class="mb-7 sticky top-0 z-10 bg-n-background">
|
||||
<Breadcrumb :items="breadcrumbItems" @click="handleBreadcrumbClick" />
|
||||
</header>
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import AddNewRulesDialog from './AddNewRulesDialog.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/AddNewRulesDialog"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="px-4 py-4 bg-n-background h-[200px]">
|
||||
<AddNewRulesDialog
|
||||
button-label="Add a guardrail"
|
||||
placeholder="Type in another guardrail..."
|
||||
confirm-label="Create"
|
||||
cancel-label="Cancel"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { useToggle } from '@vueuse/core';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
confirmLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cancelLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const [showPopover, togglePopover] = useToggle();
|
||||
const onClickAdd = () => {
|
||||
if (!modelValue.value?.trim()) return;
|
||||
emit('add', modelValue.value.trim());
|
||||
modelValue.value = '';
|
||||
togglePopover(false);
|
||||
};
|
||||
|
||||
const onClickCancel = () => {
|
||||
togglePopover(false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inline-flex relative">
|
||||
<Button
|
||||
:label="buttonLabel"
|
||||
sm
|
||||
slate
|
||||
class="flex-shrink-0"
|
||||
@click="togglePopover(!showPopover)"
|
||||
/>
|
||||
<div
|
||||
v-if="showPopover"
|
||||
class="absolute w-[26.5rem] top-9 z-50 ltr:left-0 rtl:right-0 flex flex-col gap-5 bg-n-alpha-3 backdrop-blur-[100px] p-4 rounded-xl border border-n-weak shadow-md"
|
||||
>
|
||||
<InlineInput
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="onClickAdd"
|
||||
/>
|
||||
<div class="flex gap-2 justify-between">
|
||||
<Button
|
||||
:label="cancelLabel"
|
||||
sm
|
||||
link
|
||||
slate
|
||||
class="h-10 hover:!no-underline"
|
||||
@click="onClickCancel"
|
||||
/>
|
||||
<Button :label="confirmLabel" sm @click="onClickAdd" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup>
|
||||
import AddNewRulesInput from './AddNewRulesInput.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/AddNewRulesInput"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Default">
|
||||
<div class="px-6 py-4 bg-n-background">
|
||||
<AddNewRulesInput
|
||||
placeholder="Type in another response guideline..."
|
||||
label="Add and save (↵)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
defineProps({
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: String,
|
||||
default: '',
|
||||
});
|
||||
|
||||
const onClickAdd = () => {
|
||||
if (!modelValue.value?.trim()) return;
|
||||
emit('add', modelValue.value.trim());
|
||||
modelValue.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex py-3 ltr:pl-3 h-16 rtl:pr-3 ltr:pr-4 rtl:pl-4 items-center gap-3 rounded-xl bg-n-solid-2 outline-1 outline outline-n-container"
|
||||
>
|
||||
<Icon icon="i-lucide-plus" class="text-n-slate-10 size-5 flex-shrink-0" />
|
||||
|
||||
<InlineInput
|
||||
v-model="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@keyup.enter="onClickAdd"
|
||||
/>
|
||||
<Button
|
||||
:label="label"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onClickAdd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
allItems: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectAllLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
selectedCountLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
deleteLabel: {
|
||||
type: String,
|
||||
default: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['bulkDelete']);
|
||||
|
||||
const modelValue = defineModel({
|
||||
type: Set,
|
||||
default: () => new Set(),
|
||||
});
|
||||
|
||||
const selectedCount = computed(() => modelValue.value.size);
|
||||
const totalCount = computed(() => props.allItems.length);
|
||||
|
||||
const hasSelected = computed(() => selectedCount.value > 0);
|
||||
const isIndeterminate = computed(
|
||||
() => hasSelected.value && selectedCount.value < totalCount.value
|
||||
);
|
||||
const allSelected = computed(
|
||||
() => totalCount.value > 0 && selectedCount.value === totalCount.value
|
||||
);
|
||||
|
||||
const bulkCheckboxState = computed({
|
||||
get: () => allSelected.value,
|
||||
set: shouldSelectAll => {
|
||||
const newSelectedIds = shouldSelectAll
|
||||
? new Set(props.allItems.map(item => item.id))
|
||||
: new Set();
|
||||
modelValue.value = newSelectedIds;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||
{{ selectAllLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
:label="deleteLabel"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="emit('bulkDelete')"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-3">
|
||||
<slot name="default-actions" />
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import RuleCard from './RuleCard.vue';
|
||||
|
||||
const sampleRules = [
|
||||
{ id: 1, content: 'Block sensitive personal information', selectable: true },
|
||||
{ id: 2, content: 'Reject offensive language', selectable: true },
|
||||
{ id: 3, content: 'Deflect legal or medical advice', selectable: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/RuleCard"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Selectable List">
|
||||
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||
<RuleCard
|
||||
v-for="rule in sampleRules"
|
||||
:id="rule.id"
|
||||
:key="rule.id"
|
||||
:content="rule.content"
|
||||
:selectable="rule.selectable"
|
||||
@select="id => console.log('Selected rule', id)"
|
||||
@edit="id => console.log('Edit', id)"
|
||||
@delete="id => console.log('Delete', id)"
|
||||
/>
|
||||
</div>
|
||||
</Variant>
|
||||
|
||||
<Variant title="Non-Selectable">
|
||||
<div class="flex flex-col gap-4 px-20 py-4 bg-n-background">
|
||||
<RuleCard id="4" content="Replies should be friendly and clear." />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'hover', 'edit', 'delete']);
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const isEditing = ref(false);
|
||||
const editedContent = ref(props.content);
|
||||
|
||||
// Local content to display to avoid flicker until parent prop updates on inline edit
|
||||
const localContent = ref(props.content);
|
||||
|
||||
// Keeps localContent in sync when parent updates content prop
|
||||
watch(
|
||||
() => props.content,
|
||||
newVal => {
|
||||
localContent.value = newVal;
|
||||
}
|
||||
);
|
||||
|
||||
const startEdit = () => {
|
||||
isEditing.value = true;
|
||||
editedContent.value = props.content;
|
||||
};
|
||||
|
||||
const saveEdit = () => {
|
||||
isEditing.value = false;
|
||||
// Update local content
|
||||
localContent.value = editedContent.value;
|
||||
emit('edit', { id: props.id, content: editedContent.value });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout
|
||||
selectable
|
||||
class="relative [&>div]:!py-5 [&>div]:ltr:!pr-4 [&>div]:rtl:!pl-4"
|
||||
layout="row"
|
||||
@mouseenter="emit('hover', true)"
|
||||
@mouseleave="emit('hover', false)"
|
||||
>
|
||||
<div v-show="selectable" class="absolute top-6 ltr:left-3 rtl:right-3">
|
||||
<Checkbox v-model="modelValue" />
|
||||
</div>
|
||||
<InlineInput
|
||||
v-if="isEditing"
|
||||
v-model="editedContent"
|
||||
focus-on-mount
|
||||
custom-input-class="flex items-center gap-2 text-sm text-n-slate-12"
|
||||
@keyup.enter="saveEdit"
|
||||
/>
|
||||
<span v-else class="flex items-center gap-2 text-sm text-n-slate-12">
|
||||
{{ localContent }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="i-lucide-pen" slate xs ghost @click="startEdit" />
|
||||
<span class="w-px h-4 bg-n-weak" />
|
||||
<Button
|
||||
icon="i-lucide-trash"
|
||||
slate
|
||||
xs
|
||||
ghost
|
||||
@click="emit('delete', id)"
|
||||
/>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import SuggestedRules from './SuggestedRules.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const guidelinesExample = [
|
||||
{
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Captain/Assistant/SuggestedRules"
|
||||
:layout="{ type: 'grid', width: '800px' }"
|
||||
>
|
||||
<Variant title="Suggested Rules List">
|
||||
<div class="px-20 py-4 bg-n-background">
|
||||
<SuggestedRules
|
||||
title="Example response guidelines"
|
||||
:items="guidelinesExample"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<span class="text-sm text-n-slate-12">{{ item.content }}</span>
|
||||
<Button
|
||||
label="Add this"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['add', 'close']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const onAddClick = () => {
|
||||
emit('add');
|
||||
};
|
||||
|
||||
const onClickClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col items-start self-stretch rounded-xl w-full overflow-hidden border border-dashed border-n-strong"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-3 px-4 pb-1 pt-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<h5 class="text-sm font-medium text-n-slate-11">{{ title }}</h5>
|
||||
<span class="h-3 w-px bg-n-weak" />
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD')"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onAddClick"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
icon="i-lucide-x"
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="onClickClose"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-start divide-y divide-n-strong divide-dashed w-full"
|
||||
>
|
||||
<div v-for="item in items" :key="item.content" class="w-full px-4 py-4">
|
||||
<slot :item="item" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -331,6 +331,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
label: t('SIDEBAR.WHATSAPP'),
|
||||
to: accountScopedRoute('campaigns_whatsapp_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
@@ -36,6 +37,7 @@ export const FEATURE_FLAGS = {
|
||||
REPORT_V4: 'report_v4',
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
WHATSAPP_EMBEDDED_SIGNUP: 'whatsapp_embedded_signup',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
};
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"SEND_WEBHOOK_EVENT": "Send Webhook Event",
|
||||
"SEND_ATTACHMENT": "Send Attachment",
|
||||
"SEND_MESSAGE": "Send a Message",
|
||||
"ADD_PRIVATE_NOTE": "Add a Private Note",
|
||||
"CHANGE_PRIORITY": "Change Priority",
|
||||
"ADD_SLA": "Add SLA",
|
||||
"OPEN_CONVERSATION": "Open conversation"
|
||||
|
||||
@@ -137,6 +137,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WHATSAPP": {
|
||||
"HEADER_TITLE": "WhatsApp campaigns",
|
||||
"NEW_CAMPAIGN": "Create campaign",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No WhatsApp campaigns are available",
|
||||
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
"CAMPAIGN_DETAILS": {
|
||||
"SENT_FROM": "Sent from",
|
||||
"ON": "on"
|
||||
}
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create WhatsApp campaign",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"CREATE_BUTTON_TEXT": "Create",
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Title",
|
||||
"PLACEHOLDER": "Please enter the title of campaign",
|
||||
"ERROR": "Title is required"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Select Inbox",
|
||||
"PLACEHOLDER": "Select Inbox",
|
||||
"ERROR": "Inbox is required"
|
||||
},
|
||||
"TEMPLATE": {
|
||||
"LABEL": "WhatsApp Template",
|
||||
"PLACEHOLDER": "Select a template",
|
||||
"INFO": "Select a template to use for this campaign.",
|
||||
"ERROR": "Template is required",
|
||||
"PREVIEW_TITLE": "Process {templateName}",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"LABEL": "Audience",
|
||||
"PLACEHOLDER": "Select the customer labels",
|
||||
"ERROR": "Audience is required"
|
||||
},
|
||||
"SCHEDULED_AT": {
|
||||
"LABEL": "Scheduled time",
|
||||
"PLACEHOLDER": "Please select the time",
|
||||
"ERROR": "Scheduled time is required"
|
||||
},
|
||||
"BUTTONS": {
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
|
||||
"ERROR_MESSAGE": "There was an error. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONFIRM_DELETE": {
|
||||
"TITLE": "Are you sure to delete?",
|
||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||
|
||||
@@ -222,10 +222,17 @@
|
||||
"DESC": "Start supporting your customers via WhatsApp.",
|
||||
"PROVIDERS": {
|
||||
"LABEL": "API Provider",
|
||||
"WHATSAPP_EMBEDDED": "WhatsApp Business",
|
||||
"TWILIO": "Twilio",
|
||||
"WHATSAPP_CLOUD": "WhatsApp Cloud",
|
||||
"WHATSAPP_CLOUD_DESC": "Quick setup through Meta",
|
||||
"TWILIO_DESC": "Connect via Twilio credentials",
|
||||
"360_DIALOG": "360Dialog"
|
||||
},
|
||||
"SELECT_PROVIDER": {
|
||||
"TITLE": "Select your API provider",
|
||||
"DESCRIPTION": "Choose your WhatsApp provider. You can connect directly through Meta which requires no setup, or connect through Twilio using your account credentials."
|
||||
},
|
||||
"INBOX_NAME": {
|
||||
"LABEL": "Inbox Name",
|
||||
"PLACEHOLDER": "Please enter an inbox name",
|
||||
@@ -264,6 +271,28 @@
|
||||
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create WhatsApp Channel",
|
||||
"EMBEDDED_SIGNUP": {
|
||||
"TITLE": "Quick Setup with Meta",
|
||||
"DESC": "You will be redirected to Meta to log into your WhatsApp Business account. Having admin access will help make the setup smooth and easy.",
|
||||
"BENEFITS": {
|
||||
"TITLE": "Benefits of Embedded Signup:",
|
||||
"EASY_SETUP": "No manual configuration required",
|
||||
"SECURE_AUTH": "Secure OAuth based authentication",
|
||||
"AUTO_CONFIG": "Automatic webhook and phone number configuration"
|
||||
},
|
||||
"SUBMIT_BUTTON": "Connect with WhatsApp Business",
|
||||
"AUTH_PROCESSING": "Authenticating with Meta",
|
||||
"WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...",
|
||||
"PROCESSING": "Setting up your WhatsApp Business Account",
|
||||
"LOADING_SDK": "Loading Facebook SDK...",
|
||||
"CANCELLED": "WhatsApp Signup was cancelled",
|
||||
"SUCCESS_TITLE": "WhatsApp Business Account Connected!",
|
||||
"WAITING_FOR_AUTH": "Waiting for authentication...",
|
||||
"INVALID_BUSINESS_DATA": "Invalid business data received from Facebook. Please try again.",
|
||||
"SIGNUP_ERROR": "Signup error occurred",
|
||||
"AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.",
|
||||
"SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured"
|
||||
},
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to save the WhatsApp channel"
|
||||
}
|
||||
|
||||
@@ -536,6 +536,100 @@
|
||||
"TITLE": "Captain Assistant",
|
||||
"NOTE": "Captain Assistant engages directly with customers, learns from your help docs and past conversations, and delivers instant, accurate responses. It handles the initial queries, providing quick resolutions before transferring to an agent when needed."
|
||||
}
|
||||
},
|
||||
"GUARDRAILS": {
|
||||
"TITLE": "Guardrails",
|
||||
"DESCRIPTION": "Keeps things on track—only the kinds of questions you want your assistant to answer, nothing off-limits or off-topic.",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Guardrails"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example guardrails",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another guardrail..."
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a guardrail",
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel",
|
||||
"PLACEHOLDER": "Type in another guardrail...",
|
||||
"TEST_ALL": "Test all"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No guardrails found. Create or add examples to begin.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Guardrails added successfully",
|
||||
"ERROR": "There was an error adding guardrails, please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Guardrails updated successfully",
|
||||
"ERROR": "There was an error updating guardrails, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Guardrails deleted successfully",
|
||||
"ERROR": "There was an error deleting guardrails, please try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"RESPONSE_GUIDELINES": {
|
||||
"TITLE": "Response Guidelines",
|
||||
"DESCRIPTION": "The vibe and structure of your assistant’s replies—clear and friendly? Short and snappy? Detailed and formal?",
|
||||
"BREADCRUMB": {
|
||||
"TITLE": "Response Guidelines"
|
||||
},
|
||||
"BULK_ACTION": {
|
||||
"SELECTED": "{count} item selected | {count} items selected",
|
||||
"SELECT_ALL": "Select all ({count})",
|
||||
"UNSELECT_ALL": "Unselect all ({count})",
|
||||
"BULK_DELETE_BUTTON": "Delete"
|
||||
},
|
||||
"ADD": {
|
||||
"SUGGESTED": {
|
||||
"TITLE": "Example response guidelines",
|
||||
"ADD": "Add all",
|
||||
"ADD_SINGLE": "Add this",
|
||||
"SAVE": "Add and save (↵)",
|
||||
"PLACEHOLDER": "Type in another response guideline..."
|
||||
},
|
||||
"NEW": {
|
||||
"TITLE": "Add a response guideline",
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel",
|
||||
"PLACEHOLDER": "Type in another response guideline...",
|
||||
"TEST_ALL": "Test all"
|
||||
}
|
||||
},
|
||||
"LIST": {
|
||||
"SEARCH_PLACEHOLDER": "Search..."
|
||||
},
|
||||
"EMPTY_MESSAGE": "No response guidelines found. Create or add examples to begin.",
|
||||
"API": {
|
||||
"ADD": {
|
||||
"SUCCESS": "Response Guidelines added successfully",
|
||||
"ERROR": "There was an error adding response guidelines, please try again."
|
||||
},
|
||||
"UPDATE": {
|
||||
"SUCCESS": "Response Guidelines updated successfully",
|
||||
"ERROR": "There was an error updating response guidelines, please try again."
|
||||
},
|
||||
"DELETE": {
|
||||
"SUCCESS": "Response Guidelines deleted successfully",
|
||||
"ERROR": "There was an error deleting response guidelines, please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DOCUMENTS": {
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
"CSAT": "CSAT",
|
||||
"LIVE_CHAT": "Live Chat",
|
||||
"SMS": "SMS",
|
||||
"WHATSAPP": "WhatsApp",
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const meta = {
|
||||
@@ -50,6 +51,15 @@ const campaignsRoutes = {
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'whatsapp',
|
||||
name: 'campaigns_whatsapp_index',
|
||||
meta: {
|
||||
...meta,
|
||||
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
|
||||
},
|
||||
component: WhatsAppCampaignsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -25,8 +24,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
|
||||
|
||||
const liveChatCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
|
||||
const liveChatCampaigns = computed(
|
||||
() => getters['campaigns/getLiveChatCampaigns'].value
|
||||
);
|
||||
|
||||
const hasNoLiveChatCampaigns = computed(
|
||||
@@ -59,7 +58,7 @@ const handleDelete = campaign => {
|
||||
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
class="flex justify-center items-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -23,9 +22,7 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const SMSCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
|
||||
);
|
||||
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
|
||||
|
||||
const hasNoSMSCampaigns = computed(
|
||||
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
|
||||
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
|
||||
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
|
||||
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const selectedCampaign = ref(null);
|
||||
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
|
||||
|
||||
const uiFlags = useMapGetter('campaigns/getUIFlags');
|
||||
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const WhatsAppCampaigns = computed(
|
||||
() => getters['campaigns/getWhatsAppCampaigns'].value
|
||||
);
|
||||
|
||||
const hasNoWhatsAppCampaigns = computed(
|
||||
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
);
|
||||
|
||||
const handleDelete = campaign => {
|
||||
selectedCampaign.value = campaign;
|
||||
confirmDeleteCampaignDialogRef.value.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CampaignLayout
|
||||
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
|
||||
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
|
||||
@click="toggleWhatsAppCampaignDialog()"
|
||||
@close="toggleWhatsAppCampaignDialog(false)"
|
||||
>
|
||||
<template #action>
|
||||
<WhatsAppCampaignDialog
|
||||
v-if="showWhatsAppCampaignDialog"
|
||||
@close="toggleWhatsAppCampaignDialog(false)"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<CampaignList
|
||||
v-else-if="!hasNoWhatsAppCampaigns"
|
||||
:campaigns="WhatsAppCampaigns"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<WhatsAppCampaignEmptyState
|
||||
v-else
|
||||
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
|
||||
class="pt-14"
|
||||
/>
|
||||
<ConfirmDeleteCampaignDialog
|
||||
ref="confirmDeleteCampaignDialogRef"
|
||||
:selected-campaign="selectedCampaign"
|
||||
/>
|
||||
</CampaignLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
|
||||
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
|
||||
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
|
||||
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BREADCRUMB.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guardrailsContent = computed(() => assistant.value?.guardrails || []);
|
||||
|
||||
const displayGuardrails = computed(() =>
|
||||
guardrailsContent.value.map((c, idx) => ({ id: idx, content: c }))
|
||||
);
|
||||
|
||||
const guardrailsExample = [
|
||||
{
|
||||
id: 1,
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredGuardrails = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return displayGuardrails.value;
|
||||
return picoSearch(displayGuardrails.value, query, ['content']);
|
||||
});
|
||||
|
||||
const shouldShowSuggestedRules = computed(() => {
|
||||
return uiSettings.value?.show_guardrails_suggestions !== false;
|
||||
});
|
||||
|
||||
const closeSuggestedRules = () => {
|
||||
updateUISettings({ show_guardrails_suggestions: false });
|
||||
};
|
||||
|
||||
// Bulk selection & hover state
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleRuleSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const handleRuleHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = displayGuardrails.value.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const saveGuardrails = async list => {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
assistant: { guardrails: list },
|
||||
});
|
||||
};
|
||||
|
||||
const addGuardrail = async content => {
|
||||
try {
|
||||
const newGuardrails = [...guardrailsContent.value, content];
|
||||
await saveGuardrails(newGuardrails);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const editGuardrail = async ({ id, content }) => {
|
||||
try {
|
||||
const updated = [...guardrailsContent.value];
|
||||
updated[id] = content;
|
||||
await saveGuardrails(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGuardrail = async id => {
|
||||
try {
|
||||
const updated = guardrailsContent.value.filter((_, idx) => idx !== id);
|
||||
await saveGuardrails(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteGuardrails = async () => {
|
||||
try {
|
||||
if (bulkSelectedIds.value.size === 0) return;
|
||||
const updated = guardrailsContent.value.filter(
|
||||
(_, idx) => !bulkSelectedIds.value.has(idx)
|
||||
);
|
||||
await saveGuardrails(updated);
|
||||
bulkSelectedIds.value.clear();
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const addAllExample = () => {
|
||||
updateUISettings({ show_guardrails_suggestions: false });
|
||||
try {
|
||||
const exampleContents = guardrailsExample.map(example => example.content);
|
||||
const newGuardrails = [...guardrailsContent.value, ...exampleContents];
|
||||
saveGuardrails(newGuardrails);
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.GUARDRAILS.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<template #body>
|
||||
<SettingsHeader
|
||||
:heading="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.TITLE')"
|
||||
:description="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.DESCRIPTION')"
|
||||
/>
|
||||
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
|
||||
<SuggestedRules
|
||||
:title="$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.TITLE')"
|
||||
:items="guardrailsExample"
|
||||
@add="addAllExample"
|
||||
@close="closeSuggestedRules"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ item.content }}
|
||||
</span>
|
||||
<Button
|
||||
:label="
|
||||
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.ADD_SINGLE')
|
||||
"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="addGuardrail(item.content)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
<div class="flex mt-7 flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="displayGuardrails"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="
|
||||
$t('CAPTAIN.ASSISTANTS.GUARDRAILS.BULK_ACTION.BULK_DELETE_BUTTON')
|
||||
"
|
||||
@bulk-delete="bulkDeleteGuardrails"
|
||||
>
|
||||
<template #default-actions>
|
||||
<AddNewRulesDialog
|
||||
v-model="newDialogRule"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.PLACEHOLDER')
|
||||
"
|
||||
:button-label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TITLE')"
|
||||
:confirm-label="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CREATE')
|
||||
"
|
||||
:cancel-label="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.CANCEL')
|
||||
"
|
||||
@add="addGuardrail"
|
||||
/>
|
||||
<!-- Will enable this feature in future -->
|
||||
<!-- <div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.NEW.TEST_ALL')"
|
||||
xs
|
||||
ghost
|
||||
slate
|
||||
class="!text-sm"
|
||||
/> -->
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<div
|
||||
v-if="displayGuardrails.length && bulkSelectedIds.size === 0"
|
||||
class="max-w-[22.5rem] w-full min-w-0"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.LIST.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayGuardrails.length === 0" class="mt-1 mb-2">
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.GUARDRAILS.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<RuleCard
|
||||
v-for="guardrail in filteredGuardrails"
|
||||
:id="guardrail.id"
|
||||
:key="guardrail.id"
|
||||
:content="guardrail.content"
|
||||
:is-selected="bulkSelectedIds.has(guardrail.id)"
|
||||
:selectable="
|
||||
hoveredCard === guardrail.id || bulkSelectedIds.size > 0
|
||||
"
|
||||
@select="handleRuleSelect"
|
||||
@edit="editGuardrail"
|
||||
@delete="deleteGuardrail"
|
||||
@hover="isHovered => handleRuleHover(isHovered, guardrail.id)"
|
||||
/>
|
||||
</div>
|
||||
<AddNewRulesInput
|
||||
v-model="newInlineRule"
|
||||
:placeholder="
|
||||
t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.PLACEHOLDER')
|
||||
"
|
||||
:label="t('CAPTAIN.ASSISTANTS.GUARDRAILS.ADD.SUGGESTED.SAVE')"
|
||||
@add="addGuardrail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
import SettingsPageLayout from 'dashboard/components-next/captain/SettingsPageLayout.vue';
|
||||
import SettingsHeader from 'dashboard/components-next/captain/pageComponents/settings/SettingsHeader.vue';
|
||||
import SuggestedRules from 'dashboard/components-next/captain/assistant/SuggestedRules.vue';
|
||||
import AddNewRulesInput from 'dashboard/components-next/captain/assistant/AddNewRulesInput.vue';
|
||||
import AddNewRulesDialog from 'dashboard/components-next/captain/assistant/AddNewRulesDialog.vue';
|
||||
import RuleCard from 'dashboard/components-next/captain/assistant/RuleCard.vue';
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const assistantId = route.params.assistantId;
|
||||
const uiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingItem);
|
||||
const assistant = computed(() =>
|
||||
store.getters['captainAssistants/getRecord'](Number(assistantId))
|
||||
);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const newInlineRule = ref('');
|
||||
const newDialogRule = ref('');
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: t('CAPTAIN.ASSISTANTS.SETTINGS.BREADCRUMB.ASSISTANT'),
|
||||
routeName: 'captain_assistants_index',
|
||||
},
|
||||
{ label: assistant.value?.name, routeName: 'captain_assistants_edit' },
|
||||
{ label: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE') },
|
||||
];
|
||||
});
|
||||
|
||||
const guidelinesContent = computed(
|
||||
() => assistant.value?.response_guidelines || []
|
||||
);
|
||||
|
||||
const displayGuidelines = computed(() =>
|
||||
guidelinesContent.value.map((c, idx) => ({ id: idx, content: c }))
|
||||
);
|
||||
|
||||
const guidelinesExample = [
|
||||
{
|
||||
id: 1,
|
||||
content:
|
||||
'Block queries that share or request sensitive personal information (e.g. phone numbers, passwords).',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
content:
|
||||
'Reject queries that include offensive, discriminatory, or threatening language.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
content:
|
||||
'Deflect when the assistant is asked for legal or medical diagnosis or treatment.',
|
||||
},
|
||||
];
|
||||
|
||||
const filteredGuidelines = computed(() => {
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return displayGuidelines.value;
|
||||
return picoSearch(displayGuidelines.value, query, ['content']);
|
||||
});
|
||||
|
||||
const shouldShowSuggestedRules = computed(() => {
|
||||
return uiSettings.value?.show_response_guidelines_suggestions !== false;
|
||||
});
|
||||
|
||||
const closeSuggestedRules = () => {
|
||||
updateUISettings({ show_response_guidelines_suggestions: false });
|
||||
};
|
||||
|
||||
// Bulk selection & hover state
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const handleRuleSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const handleRuleHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = displayGuidelines.value.length || 0;
|
||||
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
|
||||
return isAllSelected
|
||||
? t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.UNSELECT_ALL', {
|
||||
count,
|
||||
})
|
||||
: t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECT_ALL', {
|
||||
count,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() => {
|
||||
return t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.SELECTED', {
|
||||
count: bulkSelectedIds.value.size,
|
||||
});
|
||||
});
|
||||
|
||||
const saveGuidelines = async list => {
|
||||
await store.dispatch('captainAssistants/update', {
|
||||
id: assistantId,
|
||||
assistant: { response_guidelines: list },
|
||||
});
|
||||
};
|
||||
|
||||
const addGuideline = async content => {
|
||||
try {
|
||||
const updated = [...guidelinesContent.value, content];
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const editGuideline = async ({ id, content }) => {
|
||||
try {
|
||||
const updated = [...guidelinesContent.value];
|
||||
updated[id] = content;
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteGuideline = async id => {
|
||||
try {
|
||||
const updated = guidelinesContent.value.filter((_, idx) => idx !== id);
|
||||
await saveGuidelines(updated);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const bulkDeleteGuidelines = async () => {
|
||||
try {
|
||||
if (bulkSelectedIds.value.size === 0) return;
|
||||
const updated = guidelinesContent.value.filter(
|
||||
(_, idx) => !bulkSelectedIds.value.has(idx)
|
||||
);
|
||||
await saveGuidelines(updated);
|
||||
bulkSelectedIds.value.clear();
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.DELETE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
const addAllExample = async () => {
|
||||
updateUISettings({ show_response_guidelines_suggestions: false });
|
||||
try {
|
||||
const exampleContents = guidelinesExample.map(example => example.content);
|
||||
const newGuidelines = [...guidelinesContent.value, ...exampleContents];
|
||||
await saveGuidelines(newGuidelines);
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.SUCCESS'));
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.API.ADD.ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsPageLayout
|
||||
:breadcrumb-items="breadcrumbItems"
|
||||
:is-fetching="isFetching"
|
||||
>
|
||||
<template #body>
|
||||
<SettingsHeader
|
||||
:heading="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
||||
:description="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.DESCRIPTION')"
|
||||
/>
|
||||
<div v-if="shouldShowSuggestedRules" class="flex mt-7 flex-col gap-4">
|
||||
<SuggestedRules
|
||||
:title="t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.TITLE')"
|
||||
:items="guidelinesExample"
|
||||
@add="addAllExample"
|
||||
@close="closeSuggestedRules"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{ item.content }}
|
||||
</span>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.ADD_SINGLE'
|
||||
)
|
||||
"
|
||||
ghost
|
||||
xs
|
||||
slate
|
||||
class="!text-sm !text-n-slate-11 flex-shrink-0"
|
||||
@click="addGuideline(item.content)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SuggestedRules>
|
||||
</div>
|
||||
<div class="flex mt-7 flex-col gap-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<BulkSelectBar
|
||||
v-model="bulkSelectedIds"
|
||||
:all-items="displayGuidelines"
|
||||
:select-all-label="buildSelectedCountLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
:delete-label="
|
||||
$t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.BULK_ACTION.BULK_DELETE_BUTTON'
|
||||
)
|
||||
"
|
||||
@bulk-delete="bulkDeleteGuidelines"
|
||||
>
|
||||
<template #default-actions>
|
||||
<AddNewRulesDialog
|
||||
v-model="newDialogRule"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:button-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TITLE')
|
||||
"
|
||||
:confirm-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CREATE')
|
||||
"
|
||||
:cancel-label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.CANCEL')
|
||||
"
|
||||
@add="addGuideline"
|
||||
/>
|
||||
<!-- Will enable this feature in future -->
|
||||
<!-- <div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.NEW.TEST_ALL')
|
||||
"
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
/> -->
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
<div
|
||||
v-if="displayGuidelines.length && bulkSelectedIds.size === 0"
|
||||
class="max-w-[22.5rem] w-full min-w-0"
|
||||
>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.LIST.SEARCH_PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayGuidelines.length === 0" class="mt-1 mb-2">
|
||||
<span class="text-n-slate-11 text-sm">
|
||||
{{ t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.EMPTY_MESSAGE') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<RuleCard
|
||||
v-for="guideline in filteredGuidelines"
|
||||
:id="guideline.id"
|
||||
:key="guideline.id"
|
||||
:content="guideline.content"
|
||||
:is-selected="bulkSelectedIds.has(guideline.id)"
|
||||
:selectable="
|
||||
hoveredCard === guideline.id || bulkSelectedIds.size > 0
|
||||
"
|
||||
@select="handleRuleSelect"
|
||||
@hover="isHovered => handleRuleHover(isHovered, guideline.id)"
|
||||
@edit="editGuideline"
|
||||
@delete="deleteGuideline"
|
||||
/>
|
||||
</div>
|
||||
<AddNewRulesInput
|
||||
v-model="newInlineRule"
|
||||
:placeholder="
|
||||
t(
|
||||
'CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.PLACEHOLDER'
|
||||
)
|
||||
"
|
||||
:label="
|
||||
t('CAPTAIN.ASSISTANTS.RESPONSE_GUIDELINES.ADD.SUGGESTED.SAVE')
|
||||
"
|
||||
@add="addGuideline"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsPageLayout>
|
||||
</template>
|
||||
@@ -32,7 +32,7 @@ const controlItems = computed(() => {
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.GUARDRAILS.DESCRIPTION'
|
||||
),
|
||||
// routeName: 'captain_assistants_guardrails_index',
|
||||
routeName: 'captain_assistants_guardrails_index',
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
@@ -50,7 +50,7 @@ const controlItems = computed(() => {
|
||||
description: t(
|
||||
'CAPTAIN.ASSISTANTS.SETTINGS.CONTROL_ITEMS.OPTIONS.RESPONSE_GUIDELINES.DESCRIPTION'
|
||||
),
|
||||
// routeName: 'captain_assistants_guidelines_index',
|
||||
routeName: 'captain_assistants_guidelines_index',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import AssistantIndex from './assistants/Index.vue';
|
||||
import AssistantEdit from './assistants/Edit.vue';
|
||||
// import AssistantSettings from './assistants/settings/Settings.vue';
|
||||
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
|
||||
import AssistantGuardrailsIndex from './assistants/guardrails/Index.vue';
|
||||
import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
|
||||
@@ -50,6 +52,36 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guardrails'
|
||||
),
|
||||
component: AssistantGuardrailsIndex,
|
||||
name: 'captain_assistants_guardrails_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL(
|
||||
'accounts/:accountId/captain/assistants/:assistantId/guidelines'
|
||||
),
|
||||
component: AssistantGuidelinesIndex,
|
||||
name: 'captain_assistants_guidelines_index',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/documents'),
|
||||
component: DocumentsIndex,
|
||||
|
||||
@@ -555,6 +555,11 @@ export const AUTOMATION_ACTION_TYPES = [
|
||||
label: 'SEND_MESSAGE',
|
||||
inputType: 'textarea',
|
||||
},
|
||||
{
|
||||
key: 'add_private_note',
|
||||
label: 'ADD_PRIVATE_NOTE',
|
||||
inputType: 'textarea',
|
||||
},
|
||||
{
|
||||
key: 'change_priority',
|
||||
label: 'CHANGE_PRIORITY',
|
||||
|
||||
@@ -47,6 +47,13 @@ export default {
|
||||
this.currentInbox.provider === 'whatsapp_cloud'
|
||||
);
|
||||
},
|
||||
// If the inbox is a whatsapp cloud inbox and the source is not embedded signup, then show the webhook details
|
||||
shouldShowWhatsAppWebhookDetails() {
|
||||
return (
|
||||
this.isWhatsAppCloudInbox &&
|
||||
this.currentInbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
},
|
||||
message() {
|
||||
if (this.isATwilioInbox) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
@@ -66,7 +73,7 @@ export default {
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (this.isWhatsAppCloudInbox) {
|
||||
if (this.isWhatsAppCloudInbox && this.shouldShowWhatsAppWebhookDetails) {
|
||||
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.SUBTITLE'
|
||||
)}`;
|
||||
@@ -113,8 +120,11 @@ export default {
|
||||
:script="currentInbox.callback_webhook_url"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isWhatsAppCloudInbox" class="w-[50%] max-w-[50%] ml-[25%]">
|
||||
<p class="mt-8 font-medium text-n-slate-11">
|
||||
<div
|
||||
v-if="shouldShowWhatsAppWebhookDetails"
|
||||
class="w-[50%] max-w-[50%] ml-[25%]"
|
||||
>
|
||||
<p class="mt-8 font-medium text-slate-700 dark:text-slate-200">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.API_CALLBACK.WEBHOOK_URL') }}
|
||||
</p>
|
||||
<woot-code lang="html" :script="currentInbox.callback_webhook_url" />
|
||||
|
||||
@@ -86,6 +86,12 @@ export default {
|
||||
selectedTabKey() {
|
||||
return this.tabs[this.selectedTabIndex]?.key;
|
||||
},
|
||||
shouldShowWhatsAppConfiguration() {
|
||||
return !!(
|
||||
this.isAWhatsAppCloudChannel &&
|
||||
this.inbox.provider_config?.source !== 'embedded_signup'
|
||||
);
|
||||
},
|
||||
whatsAppAPIProviderName() {
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return this.$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD');
|
||||
@@ -137,7 +143,7 @@ export default {
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
) {
|
||||
visibleToAllChannelTabs = [
|
||||
@@ -383,7 +389,7 @@ export default {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-grow flex-shrink w-full min-w-0 pl-0 pr-0 overflow-auto settings"
|
||||
class="overflow-auto flex-grow flex-shrink pr-0 pl-0 w-full min-w-0 settings"
|
||||
>
|
||||
<SettingIntroBanner
|
||||
:header-image="inbox.avatarUrl"
|
||||
@@ -405,7 +411,7 @@ export default {
|
||||
/>
|
||||
</woot-tabs>
|
||||
</SettingIntroBanner>
|
||||
<section class="w-full max-w-6xl mx-auto">
|
||||
<section class="mx-auto w-full max-w-6xl">
|
||||
<MicrosoftReauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
|
||||
<FacebookReauthorize v-if="facebookUnauthorized" :inbox="inbox" />
|
||||
<GoogleReauthorize v-if="googleUnauthorized" :inbox="inbox" />
|
||||
@@ -735,7 +741,7 @@ export default {
|
||||
:business-name="businessName"
|
||||
@update="toggleSenderNameType"
|
||||
/>
|
||||
<div class="flex flex-col items-start gap-2 mt-2">
|
||||
<div class="flex flex-col gap-2 items-start mt-2">
|
||||
<NextButton
|
||||
ghost
|
||||
blue
|
||||
|
||||
@@ -1,48 +1,156 @@
|
||||
<script>
|
||||
import PageHeader from '../../SettingsSubPageHeader.vue';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'vuex';
|
||||
import Twilio from './Twilio.vue';
|
||||
import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
|
||||
import CloudWhatsapp from './CloudWhatsapp.vue';
|
||||
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PageHeader,
|
||||
Twilio,
|
||||
ThreeSixtyDialogWhatsapp,
|
||||
CloudWhatsapp,
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
|
||||
const PROVIDER_TYPES = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
TWILIO: 'twilio',
|
||||
WHATSAPP_CLOUD: 'whatsapp_cloud',
|
||||
WHATSAPP_EMBEDDED: 'whatsapp_embedded',
|
||||
THREE_SIXTY_DIALOG: '360dialog',
|
||||
};
|
||||
|
||||
const hasWhatsappAppId = computed(() => {
|
||||
return (
|
||||
window.chatwootConfig?.whatsappAppId &&
|
||||
window.chatwootConfig.whatsappAppId !== 'none'
|
||||
);
|
||||
});
|
||||
|
||||
const isWhatsappEmbeddedSignupEnabled = computed(() => {
|
||||
const accountId = route.params.accountId;
|
||||
return store.getters['accounts/isFeatureEnabledonAccount'](
|
||||
accountId,
|
||||
FEATURE_FLAGS.WHATSAPP_EMBEDDED_SIGNUP
|
||||
);
|
||||
});
|
||||
|
||||
const selectedProvider = computed(() => route.query.provider);
|
||||
|
||||
const showProviderSelection = computed(() => !selectedProvider.value);
|
||||
|
||||
const showConfiguration = computed(() => Boolean(selectedProvider.value));
|
||||
|
||||
const availableProviders = computed(() => [
|
||||
{
|
||||
value: PROVIDER_TYPES.WHATSAPP,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD_DESC'),
|
||||
icon: '/assets/images/dashboard/channels/whatsapp.png',
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
provider: 'whatsapp_cloud',
|
||||
};
|
||||
{
|
||||
value: PROVIDER_TYPES.TWILIO,
|
||||
label: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO'),
|
||||
description: t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO_DESC'),
|
||||
icon: '/assets/images/dashboard/channels/twilio.png',
|
||||
},
|
||||
]);
|
||||
|
||||
const selectProvider = providerValue => {
|
||||
router.push({
|
||||
name: route.name,
|
||||
params: route.params,
|
||||
query: { provider: providerValue },
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowEmbeddedSignup = provider => {
|
||||
// Check if the feature is enabled for the account
|
||||
if (!isWhatsappEmbeddedSignupEnabled.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(provider === PROVIDER_TYPES.WHATSAPP && hasWhatsappAppId.value) ||
|
||||
provider === PROVIDER_TYPES.WHATSAPP_EMBEDDED
|
||||
);
|
||||
};
|
||||
|
||||
const shouldShowCloudWhatsapp = provider => {
|
||||
// If embedded signup feature is enabled and app ID is configured, don't show cloud whatsapp
|
||||
if (isWhatsappEmbeddedSignupEnabled.value && hasWhatsappAppId.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show cloud whatsapp when:
|
||||
// 1. Provider is whatsapp AND
|
||||
// 2. Either no app ID is configured OR embedded signup feature is disabled
|
||||
return provider === PROVIDER_TYPES.WHATSAPP;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="border border-n-weak bg-n-solid-1 rounded-t-lg border-b-0 h-full w-full p-6 col-span-6 overflow-auto"
|
||||
class="overflow-auto col-span-6 p-6 w-full h-full rounded-t-lg border border-b-0 border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<PageHeader
|
||||
:header-title="$t('INBOX_MGMT.ADD.WHATSAPP.TITLE')"
|
||||
:header-content="$t('INBOX_MGMT.ADD.WHATSAPP.DESC')"
|
||||
/>
|
||||
<div class="flex-shrink-0 flex-grow-0">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.LABEL') }}
|
||||
<select v-model="provider">
|
||||
<option value="whatsapp_cloud">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD') }}
|
||||
</option>
|
||||
<option value="twilio">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.TWILIO') }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<div v-if="showProviderSelection">
|
||||
<div class="mb-10 text-left">
|
||||
<h1 class="mb-2 text-lg font-medium text-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.TITLE') }}
|
||||
</h1>
|
||||
<p class="text-sm leading-relaxed text-slate-11">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.SELECT_PROVIDER.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-start">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.value"
|
||||
class="gap-6 px-5 py-6 w-96 rounded-2xl border transition-all duration-200 cursor-pointer border-n-weak hover:bg-n-slate-3"
|
||||
@click="selectProvider(provider.value)"
|
||||
>
|
||||
<div class="flex justify-start mb-5">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 bg-n-alpha-2"
|
||||
>
|
||||
<img
|
||||
:src="provider.icon"
|
||||
:alt="provider.label"
|
||||
class="object-contain size-[26px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-start">
|
||||
<h3 class="mb-1.5 text-sm font-medium text-slate-12">
|
||||
{{ provider.label }}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-11">
|
||||
{{ provider.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Twilio v-if="provider === 'twilio'" type="whatsapp" />
|
||||
<ThreeSixtyDialogWhatsapp v-else-if="provider === '360dialog'" />
|
||||
<CloudWhatsapp v-else />
|
||||
<div v-else-if="showConfiguration">
|
||||
<div class="px-6 py-5 rounded-2xl border bg-n-solid-2 border-n-weak">
|
||||
<WhatsappEmbeddedSignup
|
||||
v-if="shouldShowEmbeddedSignup(selectedProvider)"
|
||||
/>
|
||||
<CloudWhatsapp v-else-if="shouldShowCloudWhatsapp(selectedProvider)" />
|
||||
<Twilio
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.TWILIO"
|
||||
type="whatsapp"
|
||||
/>
|
||||
<ThreeSixtyDialogWhatsapp
|
||||
v-else-if="selectedProvider === PROVIDER_TYPES.THREE_SIXTY_DIALOG"
|
||||
/>
|
||||
<CloudWhatsapp v-else />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
import NextButton from 'next/button/Button.vue';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
import { loadScript } from 'dashboard/helper/DOMHelpers';
|
||||
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
// State
|
||||
const fbSdkLoaded = ref(false);
|
||||
const isProcessing = ref(false);
|
||||
const processingMessage = ref('');
|
||||
const authCodeReceived = ref(false);
|
||||
const authCode = ref(null);
|
||||
const businessData = ref(null);
|
||||
const isAuthenticating = ref(false);
|
||||
|
||||
// Computed
|
||||
const whatsappIconPath = '/assets/images/dashboard/channels/whatsapp.png';
|
||||
|
||||
const benefits = computed(() => [
|
||||
{
|
||||
key: 'EASY_SETUP',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.EASY_SETUP'),
|
||||
},
|
||||
{
|
||||
key: 'SECURE_AUTH',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.SECURE_AUTH'),
|
||||
},
|
||||
{
|
||||
key: 'AUTO_CONFIG',
|
||||
text: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.BENEFITS.AUTO_CONFIG'),
|
||||
},
|
||||
]);
|
||||
|
||||
const showLoader = computed(() => isAuthenticating.value || isProcessing.value);
|
||||
|
||||
// Error handling
|
||||
const handleSignupError = data => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
const errorMessage =
|
||||
data.error ||
|
||||
data.message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
};
|
||||
|
||||
const handleSignupCancellation = () => {
|
||||
isProcessing.value = false;
|
||||
authCodeReceived.value = false;
|
||||
isAuthenticating.value = false;
|
||||
};
|
||||
|
||||
const handleSignupSuccess = inboxData => {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
|
||||
if (inboxData && inboxData.id) {
|
||||
useAlert(t('INBOX_MGMT.FINISH.MESSAGE'));
|
||||
router.replace({
|
||||
name: 'settings_inboxes_add_agents',
|
||||
params: {
|
||||
page: 'new',
|
||||
inbox_id: inboxData.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK'));
|
||||
router.replace({
|
||||
name: 'settings_inbox_list',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Signup flow
|
||||
const completeSignupFlow = async businessDataParam => {
|
||||
if (!authCodeReceived.value || !authCode.value) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_NOT_COMPLETED'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
isProcessing.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.PROCESSING'
|
||||
);
|
||||
|
||||
try {
|
||||
const params = {
|
||||
code: authCode.value,
|
||||
business_id: businessDataParam.business_id,
|
||||
waba_id: businessDataParam.waba_id,
|
||||
phone_number_id: businessDataParam.phone_number_id,
|
||||
};
|
||||
|
||||
const responseData = await store.dispatch(
|
||||
'inboxes/createWhatsAppEmbeddedSignup',
|
||||
params
|
||||
);
|
||||
|
||||
authCode.value = null;
|
||||
handleSignupSuccess(responseData);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
parseAPIErrorResponse(error) ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.API.ERROR_MESSAGE');
|
||||
handleSignupError({ error: errorMessage });
|
||||
}
|
||||
};
|
||||
|
||||
const isValidBusinessData = businessDataLocal => {
|
||||
return (
|
||||
businessDataLocal &&
|
||||
businessDataLocal.business_id &&
|
||||
businessDataLocal.waba_id
|
||||
);
|
||||
};
|
||||
|
||||
// Message handling
|
||||
const handleEmbeddedSignupData = async data => {
|
||||
if (data.event === 'FINISH') {
|
||||
const businessDataLocal = data.data;
|
||||
|
||||
if (isValidBusinessData(businessDataLocal)) {
|
||||
businessData.value = businessDataLocal;
|
||||
if (authCodeReceived.value && authCode.value) {
|
||||
await completeSignupFlow(businessDataLocal);
|
||||
} else {
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_AUTH'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
handleSignupError({
|
||||
error: t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.INVALID_BUSINESS_DATA'
|
||||
),
|
||||
});
|
||||
}
|
||||
} else if (data.event === 'CANCEL') {
|
||||
handleSignupCancellation();
|
||||
} else if (data.event === 'error') {
|
||||
handleSignupError({
|
||||
error:
|
||||
data.error_message ||
|
||||
t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SIGNUP_ERROR'),
|
||||
error_id: data.error_id,
|
||||
session_id: data.session_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fbLoginCallback = response => {
|
||||
if (response.authResponse && response.authResponse.code) {
|
||||
authCode.value = response.authResponse.code;
|
||||
authCodeReceived.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.WAITING_FOR_BUSINESS_INFO'
|
||||
);
|
||||
|
||||
if (businessData.value) {
|
||||
completeSignupFlow(businessData.value);
|
||||
}
|
||||
} else if (response.error) {
|
||||
handleSignupError({ error: response.error });
|
||||
} else {
|
||||
isProcessing.value = false;
|
||||
isAuthenticating.value = false;
|
||||
useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CANCELLED'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupMessage = event => {
|
||||
// Validate origin for security - following Facebook documentation
|
||||
// https://developers.facebook.com/docs/whatsapp/embedded-signup/implementation#step-3--add-embedded-signup-to-your-website
|
||||
if (!event.origin.endsWith('facebook.com')) return;
|
||||
|
||||
// Parse and handle WhatsApp embedded signup events
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'WA_EMBEDDED_SIGNUP') {
|
||||
handleEmbeddedSignupData(data);
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON or irrelevant messages
|
||||
}
|
||||
};
|
||||
|
||||
const runFBInit = () => {
|
||||
window.FB.init({
|
||||
appId: window.chatwootConfig?.whatsappAppId,
|
||||
autoLogAppEvents: true,
|
||||
xfbml: true,
|
||||
version: window.chatwootConfig?.whatsappApiVersion || 'v22.0',
|
||||
});
|
||||
fbSdkLoaded.value = true;
|
||||
};
|
||||
|
||||
const loadFacebookSdk = async () => {
|
||||
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
|
||||
async: true,
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
});
|
||||
};
|
||||
|
||||
const tryWhatsAppLogin = () => {
|
||||
isAuthenticating.value = true;
|
||||
processingMessage.value = t(
|
||||
'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.AUTH_PROCESSING'
|
||||
);
|
||||
|
||||
window.FB.login(fbLoginCallback, {
|
||||
config_id: window.chatwootConfig?.whatsappConfigurationId,
|
||||
response_type: 'code',
|
||||
override_default_response_type: true,
|
||||
extras: {
|
||||
setup: {},
|
||||
featureType: '',
|
||||
sessionInfoVersion: '3',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const launchEmbeddedSignup = async () => {
|
||||
try {
|
||||
// Load SDK first if not loaded, following Facebook.vue pattern exactly
|
||||
await loadFacebookSdk();
|
||||
runFBInit(); // Initialize FB after loading
|
||||
|
||||
// Now proceed with login
|
||||
tryWhatsAppLogin();
|
||||
} catch (error) {
|
||||
handleSignupError({
|
||||
error: t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SDK_LOAD_ERROR'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
const setupMessageListener = () => {
|
||||
window.addEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const cleanupMessageListener = () => {
|
||||
window.removeEventListener('message', handleSignupMessage);
|
||||
};
|
||||
|
||||
const initialize = () => {
|
||||
window.fbAsyncInit = runFBInit;
|
||||
setupMessageListener();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMessageListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<LoadingState v-if="showLoader" :message="processingMessage" />
|
||||
|
||||
<div v-else>
|
||||
<div class="flex flex-col items-start mb-6 text-start">
|
||||
<div class="flex justify-start mb-6">
|
||||
<div
|
||||
class="flex justify-center items-center w-12 h-12 rounded-full bg-n-alpha-2"
|
||||
>
|
||||
<img
|
||||
:src="whatsappIconPath"
|
||||
:alt="$t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WHATSAPP_CLOUD')"
|
||||
class="object-contain w-8 h-8"
|
||||
draggable="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-base font-medium text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.TITLE') }}
|
||||
</h3>
|
||||
<p class="text-sm leading-[24px] text-n-slate-12">
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.DESC') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
<div
|
||||
v-for="benefit in benefits"
|
||||
:key="benefit.key"
|
||||
class="flex gap-2 items-center text-sm text-n-slate-11"
|
||||
>
|
||||
<Icon icon="i-lucide-check" class="text-n-slate-11 size-4" />
|
||||
{{ benefit.text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-4">
|
||||
<NextButton
|
||||
:disabled="isAuthenticating"
|
||||
:is-loading="isAuthenticating"
|
||||
faded
|
||||
slate
|
||||
class="w-full"
|
||||
@click="launchEmbeddedSignup"
|
||||
>
|
||||
{{ $t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUBMIT_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,6 +3,8 @@ import types from '../mutation-types';
|
||||
import CampaignsAPI from '../../api/campaigns';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export const state = {
|
||||
records: [],
|
||||
@@ -16,10 +18,35 @@ export const getters = {
|
||||
getUIFlags(_state) {
|
||||
return _state.uiFlags;
|
||||
},
|
||||
getCampaigns: _state => campaignType => {
|
||||
return _state.records
|
||||
.filter(record => record.campaign_type === campaignType)
|
||||
.sort((a1, a2) => a1.id - a2.id);
|
||||
getCampaigns:
|
||||
_state =>
|
||||
(campaignType, inboxChannelTypes = null) => {
|
||||
let filteredRecords = _state.records.filter(
|
||||
record => record.campaign_type === campaignType
|
||||
);
|
||||
|
||||
if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) {
|
||||
filteredRecords = filteredRecords.filter(record => {
|
||||
return (
|
||||
record.inbox &&
|
||||
inboxChannelTypes.includes(record.inbox.channel_type)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filteredRecords.sort((a1, a2) => a1.id - a2.id);
|
||||
},
|
||||
getSMSCampaigns: (_state, _getters) => {
|
||||
const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes);
|
||||
},
|
||||
getWhatsAppCampaigns: (_state, _getters) => {
|
||||
const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes);
|
||||
},
|
||||
getLiveChatCampaigns: (_state, _getters) => {
|
||||
const liveChatChannelTypes = [INBOX_TYPES.WEB];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes);
|
||||
},
|
||||
getAllCampaigns: _state => {
|
||||
return _state.records;
|
||||
|
||||
@@ -5,6 +5,7 @@ import InboxesAPI from '../../api/inboxes';
|
||||
import WebChannel from '../../api/channel/webChannel';
|
||||
import FBChannel from '../../api/channel/fbChannel';
|
||||
import TwilioChannel from '../../api/channel/twilioChannel';
|
||||
import WhatsappChannel from '../../api/channel/whatsappChannel';
|
||||
import { throwErrorMessage } from '../utils/api';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
@@ -95,6 +96,11 @@ export const getters = {
|
||||
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
||||
);
|
||||
},
|
||||
getWhatsAppInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type === INBOX_TYPES.WHATSAPP
|
||||
);
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
@@ -198,6 +204,19 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
|
||||
try {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
|
||||
const response = await WhatsappChannel.createEmbeddedSignup(params);
|
||||
commit(types.default.ADD_INBOXES, response.data);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
sendAnalyticsEvent('whatsapp');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
...channelActions,
|
||||
// TODO: Extract other create channel methods to separate files to reduce file size
|
||||
// - createChannel
|
||||
|
||||
@@ -11,6 +11,11 @@ export default [
|
||||
url: 'https://github.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
inbox: {
|
||||
id: 1,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
name: 'Web Widget',
|
||||
},
|
||||
created_at: '2021-05-03T04:53:36.354Z',
|
||||
updated_at: '2021-05-03T04:53:36.354Z',
|
||||
},
|
||||
@@ -24,6 +29,11 @@ export default [
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
inbox: {
|
||||
id: 2,
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
name: 'Twilio SMS',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
@@ -39,7 +49,52 @@ export default [
|
||||
url: 'https://noshow.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
inbox: {
|
||||
id: 3,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
name: 'Web Widget 2',
|
||||
},
|
||||
created_at: '2021-05-03T10:22:51.025Z',
|
||||
updated_at: '2021-05-03T10:22:51.025Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'WhatsApp Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
message: 'Hello {{name}}, your order is ready!',
|
||||
enabled: true,
|
||||
trigger_rules: {},
|
||||
inbox: {
|
||||
id: 4,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
name: 'WhatsApp Business',
|
||||
},
|
||||
template_params: {
|
||||
name: 'order_ready',
|
||||
namespace: 'business_namespace',
|
||||
language: 'en_US',
|
||||
processed_params: { name: 'John' },
|
||||
},
|
||||
created_at: '2021-05-03T12:15:35.828Z',
|
||||
updated_at: '2021-05-03T12:15:35.828Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'SMS Promotion',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
message: 'Get 20% off your next order!',
|
||||
enabled: true,
|
||||
trigger_rules: {},
|
||||
inbox: {
|
||||
id: 5,
|
||||
channel_type: 'Channel::Sms',
|
||||
name: 'SMS Channel',
|
||||
},
|
||||
created_at: '2021-05-03T14:15:35.828Z',
|
||||
updated_at: '2021-05-03T14:15:35.828Z',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,20 +13,58 @@ describe('#getters', () => {
|
||||
it('get one_off campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(getters.getCampaigns(state)('one_off')).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
title: 'Onboarding Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
campaigns[1],
|
||||
campaigns[3],
|
||||
campaigns[4],
|
||||
]);
|
||||
});
|
||||
|
||||
trigger_rules: {
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
it('get campaigns by channel type', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(
|
||||
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
|
||||
).toEqual([campaigns[3]]);
|
||||
});
|
||||
|
||||
it('get campaigns by multiple channel types', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(
|
||||
getters.getCampaigns(state)('one_off', [
|
||||
'Channel::TwilioSms',
|
||||
'Channel::Sms',
|
||||
])
|
||||
).toEqual([campaigns[1], campaigns[4]]);
|
||||
});
|
||||
|
||||
it('get SMS campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[1],
|
||||
campaigns[4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('get WhatsApp campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it('get Live Chat campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[0],
|
||||
campaigns[2],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Conversations::UpdateMessageStatusJob < ApplicationJob
|
||||
queue_as :low
|
||||
queue_as :deferred
|
||||
|
||||
# This job only support marking messages as read or delivered, update this array if we want to support more statuses
|
||||
VALID_STATUSES = %w[read delivered].freeze
|
||||
|
||||
@@ -41,7 +41,8 @@ class AutomationRule < ApplicationRecord
|
||||
|
||||
def actions_attributes
|
||||
%w[send_message add_label remove_label send_email_to_team assign_team assign_agent send_webhook_event mute_conversation
|
||||
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript].freeze
|
||||
send_attachment change_status resolve_conversation open_conversation snooze_conversation change_priority send_email_transcript
|
||||
add_private_note].freeze
|
||||
end
|
||||
|
||||
def file_base_data
|
||||
|
||||
+15
-4
@@ -10,6 +10,7 @@
|
||||
# enabled :boolean default(TRUE)
|
||||
# message :text not null
|
||||
# scheduled_at :datetime
|
||||
# template_params :jsonb
|
||||
# title :string not null
|
||||
# trigger_only_during_business_hours :boolean default(FALSE)
|
||||
# trigger_rules :jsonb
|
||||
@@ -57,12 +58,22 @@ class Campaign < ApplicationRecord
|
||||
return unless one_off?
|
||||
return if completed?
|
||||
|
||||
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
|
||||
execute_campaign
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_campaign
|
||||
case inbox.inbox_type
|
||||
when 'Twilio SMS'
|
||||
Twilio::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Sms'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Whatsapp'
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_id
|
||||
reload
|
||||
end
|
||||
@@ -70,14 +81,14 @@ class Campaign < ApplicationRecord
|
||||
def validate_campaign_inbox
|
||||
return unless inbox
|
||||
|
||||
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type
|
||||
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type
|
||||
end
|
||||
|
||||
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
|
||||
def ensure_correct_campaign_attributes
|
||||
return if inbox.blank?
|
||||
|
||||
if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type)
|
||||
if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type)
|
||||
self.campaign_type = 'one_off'
|
||||
self.scheduled_at ||= Time.now.utc
|
||||
else
|
||||
|
||||
@@ -33,6 +33,7 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
validate :validate_provider_config
|
||||
|
||||
after_create :sync_templates
|
||||
after_create_commit :setup_webhooks
|
||||
|
||||
def name
|
||||
'Whatsapp'
|
||||
@@ -67,4 +68,41 @@ class Channel::Whatsapp < ApplicationRecord
|
||||
def validate_provider_config
|
||||
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
|
||||
end
|
||||
|
||||
def setup_webhooks
|
||||
return unless should_setup_webhooks?
|
||||
|
||||
perform_webhook_setup
|
||||
rescue StandardError => e
|
||||
handle_webhook_setup_error(e)
|
||||
end
|
||||
|
||||
def should_setup_webhooks?
|
||||
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present?
|
||||
end
|
||||
|
||||
def whatsapp_cloud_provider?
|
||||
provider == 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def embedded_signup_source?
|
||||
provider_config['source'] == 'embedded_signup'
|
||||
end
|
||||
|
||||
def webhook_config_present?
|
||||
provider_config['business_account_id'].present? && provider_config['api_key'].present?
|
||||
end
|
||||
|
||||
def perform_webhook_setup
|
||||
business_account_id = provider_config['business_account_id']
|
||||
api_key = provider_config['api_key']
|
||||
|
||||
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
|
||||
end
|
||||
|
||||
def handle_webhook_setup_error(error)
|
||||
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
|
||||
# Don't raise the error to prevent channel creation from failing
|
||||
# Webhooks can be retried later
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,4 +17,4 @@ class MessageContentPresenter < SimpleDelegator
|
||||
def survey_url(conversation_uuid)
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/survey/responses/#{conversation_uuid}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -47,6 +47,13 @@ class AutomationRules::ActionService < ActionService
|
||||
Messages::MessageBuilder.new(nil, @conversation, params).perform
|
||||
end
|
||||
|
||||
def add_private_note(message)
|
||||
return if conversation_a_tweet?
|
||||
|
||||
params = { content: message[0], private: true, content_attributes: { automation_rule_id: @rule.id } }
|
||||
Messages::MessageBuilder.new(nil, @conversation.reload, params).perform
|
||||
end
|
||||
|
||||
def send_email_to_team(params)
|
||||
teams = Team.where(id: params[0][:team_ids])
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class Whatsapp::ChannelCreationService
|
||||
def initialize(account, waba_info, phone_info, access_token)
|
||||
@account = account
|
||||
@waba_info = waba_info
|
||||
@phone_info = phone_info
|
||||
@access_token = access_token
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
existing_channel = find_existing_channel
|
||||
raise "Channel already exists: #{existing_channel.phone_number}" if existing_channel
|
||||
|
||||
create_channel_with_inbox
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Account is required' if @account.blank?
|
||||
raise ArgumentError, 'WABA info is required' if @waba_info.blank?
|
||||
raise ArgumentError, 'Phone info is required' if @phone_info.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def find_existing_channel
|
||||
Channel::Whatsapp.find_by(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number]
|
||||
)
|
||||
end
|
||||
|
||||
def create_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel = create_channel
|
||||
create_inbox(channel)
|
||||
channel.reload
|
||||
channel
|
||||
end
|
||||
end
|
||||
|
||||
def create_channel
|
||||
Channel::Whatsapp.create!(
|
||||
account: @account,
|
||||
phone_number: @phone_info[:phone_number],
|
||||
provider: 'whatsapp_cloud',
|
||||
provider_config: build_provider_config
|
||||
)
|
||||
end
|
||||
|
||||
def build_provider_config
|
||||
{
|
||||
api_key: @access_token,
|
||||
phone_number_id: @phone_info[:phone_number_id],
|
||||
business_account_id: @waba_info[:waba_id],
|
||||
source: 'embedded_signup'
|
||||
}
|
||||
end
|
||||
|
||||
def create_inbox(channel)
|
||||
inbox_name = build_inbox_name
|
||||
|
||||
Inbox.create!(
|
||||
account: @account,
|
||||
name: inbox_name,
|
||||
channel: channel
|
||||
)
|
||||
end
|
||||
|
||||
def build_inbox_name
|
||||
business_name = @phone_info[:business_name] || @waba_info[:business_name]
|
||||
"#{business_name} WhatsApp"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,44 @@
|
||||
class Whatsapp::EmbeddedSignupService
|
||||
def initialize(account:, code:, business_id:, waba_id:, phone_number_id:)
|
||||
@account = account
|
||||
@code = code
|
||||
@business_id = business_id
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
|
||||
# Exchange code for user access token
|
||||
access_token = Whatsapp::TokenExchangeService.new(@code).perform
|
||||
|
||||
# Fetch phone information
|
||||
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
|
||||
|
||||
# Validate token has access to the WABA
|
||||
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
|
||||
|
||||
# Create channel
|
||||
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
|
||||
|
||||
# Webhook setup is now handled in the channel after_create_commit callback
|
||||
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
|
||||
raise e
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
missing_params = []
|
||||
missing_params << 'code' if @code.blank?
|
||||
missing_params << 'business_id' if @business_id.blank?
|
||||
missing_params << 'waba_id' if @waba_id.blank?
|
||||
|
||||
return if missing_params.empty?
|
||||
|
||||
raise ArgumentError, "Required parameters are missing: #{missing_params.join(', ')}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
class Whatsapp::FacebookApiClient
|
||||
BASE_URI = 'https://graph.facebook.com'.freeze
|
||||
|
||||
def initialize(access_token = nil)
|
||||
@access_token = access_token
|
||||
@api_version = GlobalConfigService.load('WHATSAPP_API_VERSION', 'v22.0')
|
||||
end
|
||||
|
||||
def exchange_code_for_token(code)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/oauth/access_token",
|
||||
query: {
|
||||
client_id: GlobalConfigService.load('WHATSAPP_APP_ID', ''),
|
||||
client_secret: GlobalConfigService.load('WHATSAPP_APP_SECRET', ''),
|
||||
code: code
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token exchange failed')
|
||||
end
|
||||
|
||||
def fetch_phone_numbers(waba_id)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/phone_numbers",
|
||||
query: { access_token: @access_token }
|
||||
)
|
||||
|
||||
handle_response(response, 'WABA phone numbers fetch failed')
|
||||
end
|
||||
|
||||
def debug_token(input_token)
|
||||
response = HTTParty.get(
|
||||
"#{BASE_URI}/#{@api_version}/debug_token",
|
||||
query: {
|
||||
input_token: input_token,
|
||||
access_token: build_app_access_token
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response, 'Token validation failed')
|
||||
end
|
||||
|
||||
def register_phone_number(phone_number_id, pin)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{phone_number_id}/register",
|
||||
headers: request_headers,
|
||||
body: { messaging_product: 'whatsapp', pin: pin.to_s }.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Phone registration failed')
|
||||
end
|
||||
|
||||
def subscribe_waba_webhook(waba_id, callback_url, verify_token)
|
||||
response = HTTParty.post(
|
||||
"#{BASE_URI}/#{@api_version}/#{waba_id}/subscribed_apps",
|
||||
headers: request_headers,
|
||||
body: {
|
||||
override_callback_uri: callback_url,
|
||||
verify_token: verify_token
|
||||
}.to_json
|
||||
)
|
||||
|
||||
handle_response(response, 'Webhook subscription failed')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_headers
|
||||
{
|
||||
'Authorization' => "Bearer #{@access_token}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def build_app_access_token
|
||||
app_id = GlobalConfigService.load('WHATSAPP_APP_ID', '')
|
||||
app_secret = GlobalConfigService.load('WHATSAPP_APP_SECRET', '')
|
||||
"#{app_id}|#{app_secret}"
|
||||
end
|
||||
|
||||
def handle_response(response, error_message)
|
||||
raise "#{error_message}: #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
class Whatsapp::OneoffCampaignService
|
||||
pattr_initialize [:campaign!]
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
process_audience(extract_audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, to: :campaign
|
||||
delegate :channel, to: :inbox
|
||||
|
||||
def validate_campaign_type!
|
||||
raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off?
|
||||
end
|
||||
|
||||
def whatsapp_campaign?
|
||||
campaign.inbox.inbox_type == 'Whatsapp'
|
||||
end
|
||||
|
||||
def validate_campaign_status!
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
end
|
||||
|
||||
def validate_provider!
|
||||
raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def validate_feature_flag!
|
||||
raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def validate_campaign!
|
||||
validate_campaign_type!
|
||||
validate_campaign_status!
|
||||
validate_provider!
|
||||
validate_feature_flag!
|
||||
end
|
||||
|
||||
def extract_audience_labels
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
end
|
||||
|
||||
def process_contact(contact)
|
||||
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
|
||||
|
||||
if contact.phone_number.blank?
|
||||
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
|
||||
return
|
||||
end
|
||||
|
||||
if campaign.template_params.blank?
|
||||
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
|
||||
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
|
||||
|
||||
contacts.each { |contact| process_contact(contact) }
|
||||
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
return if name.blank?
|
||||
|
||||
channel.send_template(to, {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
})
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
class Whatsapp::PhoneInfoService
|
||||
def initialize(waba_id, phone_number_id, access_token)
|
||||
@waba_id = waba_id
|
||||
@phone_number_id = phone_number_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
fetch_and_process_phone_info
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def fetch_and_process_phone_info
|
||||
response = @api_client.fetch_phone_numbers(@waba_id)
|
||||
phone_numbers = response['data']
|
||||
|
||||
phone_data = find_phone_data(phone_numbers)
|
||||
raise "No phone numbers found for WABA #{@waba_id}" if phone_data.nil?
|
||||
|
||||
build_phone_info(phone_data)
|
||||
end
|
||||
|
||||
def find_phone_data(phone_numbers)
|
||||
return nil if phone_numbers.blank?
|
||||
|
||||
if @phone_number_id.present?
|
||||
phone_numbers.find { |phone| phone['id'] == @phone_number_id } || phone_numbers.first
|
||||
else
|
||||
phone_numbers.first
|
||||
end
|
||||
end
|
||||
|
||||
def build_phone_info(phone_data)
|
||||
display_phone_number = sanitize_phone_number(phone_data['display_phone_number'])
|
||||
|
||||
{
|
||||
phone_number_id: phone_data['id'],
|
||||
phone_number: "+#{display_phone_number}",
|
||||
verified: phone_data['code_verification_status'] == 'VERIFIED',
|
||||
business_name: phone_data['verified_name'] || phone_data['display_phone_number']
|
||||
}
|
||||
end
|
||||
|
||||
def sanitize_phone_number(phone_number)
|
||||
return phone_number if phone_number.blank?
|
||||
|
||||
phone_number.gsub(/[\s\-\(\)\.\+]/, '').strip
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,13 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
name, namespace, lang_code, processed_parameters = processable_channel_message_template
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: template_params,
|
||||
message: message
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
return if name.blank?
|
||||
|
||||
@@ -28,86 +34,6 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
end
|
||||
|
||||
def processable_channel_message_template
|
||||
if template_params.present?
|
||||
return [
|
||||
template_params['name'],
|
||||
template_params['namespace'],
|
||||
template_params['language'],
|
||||
processed_templates_params(template_params)
|
||||
]
|
||||
end
|
||||
|
||||
# Delete the following logic once the update for template_params is stable
|
||||
# see if we can match the message content to a template
|
||||
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
|
||||
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
|
||||
# Then we use regex to parse the template varibles and convert them into the proper payload
|
||||
channel.message_templates&.each do |template|
|
||||
match_obj = template_match_object(template)
|
||||
next if match_obj.blank?
|
||||
|
||||
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
|
||||
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
|
||||
|
||||
# no need to look up further end the search
|
||||
return [template['name'], template['namespace'], template['language'], processed_parameters]
|
||||
end
|
||||
[nil, nil, nil, nil]
|
||||
end
|
||||
|
||||
def template_match_object(template)
|
||||
body_object = validated_body_object(template)
|
||||
return if body_object.blank?
|
||||
|
||||
template_match_regex = build_template_match_regex(body_object['text'])
|
||||
message.outgoing_content.match(template_match_regex)
|
||||
end
|
||||
|
||||
def build_template_match_regex(template_text)
|
||||
# Converts the whatsapp template to a comparable regex string to check against the message content
|
||||
# the variables are of the format {{num}} ex:{{1}}
|
||||
|
||||
# transform the template text into a regex string
|
||||
# we need to replace the {{num}} with matchers that can be used to capture the variables
|
||||
template_text = template_text.gsub(/{{\d}}/, '(.*)')
|
||||
# escape if there are regex characters in the template text
|
||||
template_text = Regexp.escape(template_text)
|
||||
# ensuring only the variables remain as capture groups
|
||||
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
|
||||
|
||||
template_match_string = "^#{template_text}$"
|
||||
Regexp.new template_match_string
|
||||
end
|
||||
|
||||
def template(template_params)
|
||||
channel.message_templates.find do |t|
|
||||
t['name'] == template_params['name'] && t['language'] == template_params['language']
|
||||
end
|
||||
end
|
||||
|
||||
def processed_templates_params(template_params)
|
||||
template = template(template_params)
|
||||
return if template.blank?
|
||||
|
||||
parameter_format = template['parameter_format']
|
||||
|
||||
if parameter_format == 'NAMED'
|
||||
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
|
||||
else
|
||||
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
||||
end
|
||||
end
|
||||
|
||||
def validated_body_object(template)
|
||||
# we don't care if its not approved template
|
||||
return if template['status'] != 'approved'
|
||||
|
||||
# we only care about text body object in template. if not present we discard the template
|
||||
# we don't support other forms of templates
|
||||
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
|
||||
end
|
||||
|
||||
def send_session_message
|
||||
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
class Whatsapp::TemplateProcessorService
|
||||
pattr_initialize [:channel!, :template_params, :message]
|
||||
|
||||
def call
|
||||
if template_params.present?
|
||||
process_template_with_params
|
||||
else
|
||||
process_template_from_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_template_with_params
|
||||
[
|
||||
template_params['name'],
|
||||
template_params['namespace'],
|
||||
template_params['language'],
|
||||
processed_templates_params
|
||||
]
|
||||
end
|
||||
|
||||
def process_template_from_message
|
||||
return [nil, nil, nil, nil] if message.blank?
|
||||
|
||||
# Delete the following logic once the update for template_params is stable
|
||||
# see if we can match the message content to a template
|
||||
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
|
||||
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
|
||||
# Then we use regex to parse the template varibles and convert them into the proper payload
|
||||
channel.message_templates&.each do |template|
|
||||
match_obj = template_match_object(template)
|
||||
next if match_obj.blank?
|
||||
|
||||
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
|
||||
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
|
||||
|
||||
# no need to look up further end the search
|
||||
return [template['name'], template['namespace'], template['language'], processed_parameters]
|
||||
end
|
||||
[nil, nil, nil, nil]
|
||||
end
|
||||
|
||||
def template_match_object(template)
|
||||
body_object = validated_body_object(template)
|
||||
return if body_object.blank?
|
||||
|
||||
template_match_regex = build_template_match_regex(body_object['text'])
|
||||
message.outgoing_content.match(template_match_regex)
|
||||
end
|
||||
|
||||
def build_template_match_regex(template_text)
|
||||
# Converts the whatsapp template to a comparable regex string to check against the message content
|
||||
# the variables are of the format {{num}} ex:{{1}}
|
||||
|
||||
# transform the template text into a regex string
|
||||
# we need to replace the {{num}} with matchers that can be used to capture the variables
|
||||
template_text = template_text.gsub(/{{\d}}/, '(.*)')
|
||||
# escape if there are regex characters in the template text
|
||||
template_text = Regexp.escape(template_text)
|
||||
# ensuring only the variables remain as capture groups
|
||||
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
|
||||
|
||||
template_match_string = "^#{template_text}$"
|
||||
Regexp.new template_match_string
|
||||
end
|
||||
|
||||
def find_template
|
||||
channel.message_templates.find do |t|
|
||||
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
|
||||
end
|
||||
end
|
||||
|
||||
def processed_templates_params
|
||||
template = find_template
|
||||
return if template.blank?
|
||||
|
||||
parameter_format = template['parameter_format']
|
||||
|
||||
if parameter_format == 'NAMED'
|
||||
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
|
||||
else
|
||||
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
||||
end
|
||||
end
|
||||
|
||||
def validated_body_object(template)
|
||||
# we don't care if its not approved template
|
||||
return if template['status'] != 'approved'
|
||||
|
||||
# we only care about text body object in template. if not present we discard the template
|
||||
# we don't support other forms of templates
|
||||
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class Whatsapp::TokenExchangeService
|
||||
def initialize(code)
|
||||
@code = code
|
||||
@api_client = Whatsapp::FacebookApiClient.new
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_code!
|
||||
exchange_token
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_code!
|
||||
raise ArgumentError, 'Authorization code is required' if @code.blank?
|
||||
end
|
||||
|
||||
def exchange_token
|
||||
response = @api_client.exchange_code_for_token(@code)
|
||||
access_token = response['access_token']
|
||||
|
||||
raise "No access token in response: #{response}" if access_token.blank?
|
||||
|
||||
access_token
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Whatsapp::TokenValidationService
|
||||
def initialize(access_token, waba_id)
|
||||
@access_token = access_token
|
||||
@waba_id = waba_id
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
validate_token_waba_access
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
end
|
||||
|
||||
def validate_token_waba_access
|
||||
token_debug_data = @api_client.debug_token(@access_token)
|
||||
waba_scope = extract_waba_scope(token_debug_data)
|
||||
verify_waba_authorization(waba_scope)
|
||||
end
|
||||
|
||||
def extract_waba_scope(token_data)
|
||||
granular_scopes = token_data.dig('data', 'granular_scopes')
|
||||
waba_scope = granular_scopes&.find { |scope| scope['scope'] == 'whatsapp_business_management' }
|
||||
|
||||
raise 'No WABA scope found in token' unless waba_scope
|
||||
|
||||
waba_scope
|
||||
end
|
||||
|
||||
def verify_waba_authorization(waba_scope)
|
||||
authorized_waba_ids = waba_scope['target_ids'] || []
|
||||
|
||||
return if authorized_waba_ids.include?(@waba_id)
|
||||
|
||||
raise "Token does not have access to WABA #{@waba_id}. Authorized WABAs: #{authorized_waba_ids}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
class Whatsapp::WebhookSetupService
|
||||
def initialize(channel, waba_id, access_token)
|
||||
@channel = channel
|
||||
@waba_id = waba_id
|
||||
@access_token = access_token
|
||||
@api_client = Whatsapp::FacebookApiClient.new(access_token)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_parameters!
|
||||
register_phone_number
|
||||
setup_webhook
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_parameters!
|
||||
raise ArgumentError, 'Channel is required' if @channel.blank?
|
||||
raise ArgumentError, 'WABA ID is required' if @waba_id.blank?
|
||||
raise ArgumentError, 'Access token is required' if @access_token.blank?
|
||||
end
|
||||
|
||||
def register_phone_number
|
||||
phone_number_id = @channel.provider_config['phone_number_id']
|
||||
pin = fetch_or_create_pin
|
||||
|
||||
@api_client.register_phone_number(phone_number_id, pin)
|
||||
store_pin(pin)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[WHATSAPP] Phone registration failed but continuing: #{e.message}")
|
||||
# Continue with webhook setup even if registration fails
|
||||
# This is just a warning, not a blocking error
|
||||
end
|
||||
|
||||
def fetch_or_create_pin
|
||||
# Check if we have a stored PIN for this phone number
|
||||
existing_pin = @channel.provider_config['verification_pin']
|
||||
return existing_pin.to_i if existing_pin.present?
|
||||
|
||||
# Generate a new 6-digit PIN if none exists
|
||||
SecureRandom.random_number(900_000) + 100_000
|
||||
end
|
||||
|
||||
def store_pin(pin)
|
||||
# Store the PIN in provider_config for future use
|
||||
@channel.provider_config['verification_pin'] = pin
|
||||
@channel.save!
|
||||
end
|
||||
|
||||
def setup_webhook
|
||||
callback_url = build_callback_url
|
||||
verify_token = @channel.provider_config['webhook_verify_token']
|
||||
|
||||
@api_client.subscribe_waba_webhook(@waba_id, callback_url, verify_token)
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[WHATSAPP] Webhook setup failed: #{e.message}")
|
||||
raise "Webhook setup failed: #{e.message}"
|
||||
end
|
||||
|
||||
def build_callback_url
|
||||
frontend_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
phone_number = @channel.phone_number
|
||||
|
||||
"#{frontend_url}/webhooks/whatsapp/#{phone_number}"
|
||||
end
|
||||
end
|
||||
@@ -4,4 +4,4 @@ json.payload do
|
||||
json.partial! 'article', formats: [:json], article: article
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/inbox', formats: [:json], resource: @inbox
|
||||
@@ -9,6 +9,7 @@ json.sender do
|
||||
json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present?
|
||||
end
|
||||
json.message resource.message
|
||||
json.template_params resource.template_params
|
||||
json.campaign_status resource.campaign_status
|
||||
json.enabled resource.enabled
|
||||
json.campaign_type resource.campaign_type
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
googleOAuthClientId: '<%= ENV.fetch('GOOGLE_OAUTH_CLIENT_ID', nil) %>',
|
||||
googleOAuthCallbackUrl: '<%= ENV.fetch('GOOGLE_OAUTH_CALLBACK_URL', nil) %>',
|
||||
fbApiVersion: '<%= @global_config['FACEBOOK_API_VERSION'] %>',
|
||||
whatsappAppId: '<%= @global_config['WHATSAPP_APP_ID'] %>',
|
||||
whatsappConfigurationId: '<%= @global_config['WHATSAPP_CONFIGURATION_ID'] %>',
|
||||
whatsappApiVersion: '<%= @global_config['WHATSAPP_API_VERSION'] %>',
|
||||
signupEnabled: '<%= @global_config['ENABLE_ACCOUNT_SIGNUP'] %>',
|
||||
isEnterprise: '<%= @global_config['IS_ENTERPRISE'] %>',
|
||||
<% if @global_config['IS_ENTERPRISE'] %>
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
<symbol id="icon-microsoft" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M2 3h9v9H2zm9 19H2v-9h9zM21 3v9h-9V3zm0 19h-9v-9h9z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-google" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</symbol>
|
||||
<symbol id="icon-cancel" viewBox="0 0 48 48">
|
||||
<path fill-rule="evenodd" d="M24 19.757l-8.485-8.485c-.784-.783-2.047-.782-2.827 0l-1.417 1.416c-.777.777-.78 2.046.002 2.827L19.757 24l-8.485 8.485c-.783.784-.782 2.047 0 2.827l1.416 1.417c.777.777 2.046.78 2.827-.002L24 28.243l8.485 8.485c.784.783 2.047.782 2.827 0l1.417-1.416c.777-.777.78-2.046-.002-2.827L28.243 24l8.485-8.485c.783-.784.782-2.047 0-2.827l-1.416-1.417c-.777-.777-2.046-.78-2.827.002L24 19.757zM24 47c12.703 0 23-10.297 23-23S36.703 1 24 1 1 11.297 1 24s10.297 23 23 23z" />
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 44 KiB |
@@ -1,6 +1,4 @@
|
||||
<li
|
||||
class="px-4 border-l-4 mb-1 <%= current_page?(url) ? 'border-woot-500' : 'border-transparent' %>"
|
||||
>
|
||||
<li class="px-4 mb-1">
|
||||
<% text_class_name = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
|
||||
<%= link_to(url, class: text_class_name + " -ml-1 focus:outline-none cursor-pointer flex items-center px-2 py-1.5 text-slate-800 cursor-pointer hover:text-woot-500 hover:bg-slate-25 rounded-lg") do %>
|
||||
<svg width="16" height="16"><use xlink:href="#<%= icon %>" /></svg>
|
||||
|
||||
@@ -39,14 +39,13 @@ as defined by the routes in the `admin/` namespace
|
||||
label: display_resource_name(resource),
|
||||
}
|
||||
%>
|
||||
|
||||
<% end %>
|
||||
<%= render 'settings_menu', open: settings_open? %>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<ul class="my-4">
|
||||
<% if ChatwootApp.enterprise? %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-settings-2-line', url: super_admin_settings_url, label: 'Settings' } %>
|
||||
<% end %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-mist-fill', url: sidekiq_web_url, label: 'Sidekiq Dashboard' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-health-book-line', url: super_admin_instance_status_url, label: 'Instance Health' } %>
|
||||
<%= render partial: "nav_item", locals: { icon: 'icon-dashboard-line', url: '/', label: 'Agent Dashboard' } %>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<li class="px-4 mb-1">
|
||||
<details class="group" <%= 'open' if open %>>
|
||||
<summary class="-ml-1 flex items-center px-2 py-1.5 cursor-pointer rounded-lg <%= open ? 'text-woot-500 bg-slate-25' : 'text-slate-800 hover:text-woot-500 hover:bg-slate-25' %> list-none">
|
||||
<%= link_to super_admin_settings_url, class: 'flex items-center flex-1' do %>
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="ml-2 text-sm">Settings</span>
|
||||
<% end %>
|
||||
<svg class="ml-auto w-4 h-4 transition-transform duration-200 group-open:rotate-180" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</summary>
|
||||
<ul class="ml-4 mt-1">
|
||||
<% settings_pages.each do |_feature_key, attrs| %>
|
||||
<% url = super_admin_app_config_url(config: attrs['config_key']) %>
|
||||
<li class="px-4 mb-1">
|
||||
<% text_class = current_page?(url) ? 'text-woot-500 bg-slate-25' : 'text-slate-800' %>
|
||||
<%= link_to url, class: text_class + ' -ml-1 flex items-center px-2 py-1.5 hover:text-woot-500 hover:bg-slate-25 rounded-lg' do %>
|
||||
<span class="ml-2 text-sm"><%= attrs['name'] %></span>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
@@ -0,0 +1,4 @@
|
||||
<span class="flex gap-1 items-center bg-slate-100 h-9 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Switch to Enterprise edition</span>
|
||||
</span>
|
||||
@@ -0,0 +1,4 @@
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l-2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Upgrade now</span>
|
||||
</a>
|
||||
@@ -39,24 +39,26 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex flex-col flex-grow gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
|
||||
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
|
||||
<span>Refresh</span>
|
||||
</a>
|
||||
<% if ChatwootApp.enterprise? %>
|
||||
<div class="flex p-4 outline outline-1 outline-n-container rounded-lg items-start md:items-center shadow-sm flex-col md:flex-row">
|
||||
<div class="flex flex-col flex-grow gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="h-5 leading-5 text-n-slate-12 text-sm font-medium">Current plan</h2>
|
||||
<a href="<%= refresh_super_admin_settings_url %>" class="inline-flex gap-1 text-xs font-medium items-center text-woot-500 hover:text-woot-700">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-refresh-line" /></svg>
|
||||
<span>Refresh</span>
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
</div>
|
||||
<p class="text-n-slate-11 m-0 text-sm"><%= SuperAdmin::FeaturesHelper.plan_details.html_safe %></p>
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="px-1">Manage</span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer">
|
||||
<button class="mt-4 md:mt-0 flex gap-1 items-center bg-transparent shadow-sm h-9 hover:text-n-slate-12 hover:bg-slate-50 outline outline-1 outline-n-container rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg width="16" height="16"><use xlink:href="#icon-settings-2-line" /></svg>
|
||||
<span class="px-1">Manage</span>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if ChatwootHub.pricing_plan != 'community' && User.count > ChatwootHub.pricing_plan_quantity %>
|
||||
<div role="alert">
|
||||
@@ -99,10 +101,11 @@
|
||||
</div>
|
||||
<% if !attrs[:enabled] %>
|
||||
<div class="flex h-9 absolute top-5 items-center invisible group-hover:visible">
|
||||
<a href="<%= ChatwootHub.billing_url %>" target="_blank" rel="noopener noreferrer" class="flex gap-1 items-center bg-slate-100 h-9 hover:bg-slate-300 hover:text-n-slate-12 border border-solid border-slate-100 rounded text-n-slate-11 font-medium p-2 focus:outline-none">
|
||||
<svg class="h-4 w-4" width="24" height="24" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M13.209 3.103c-.495-1.004-1.926-1.004-2.421 0L8.43 7.88l-5.273.766c-1.107.161-1.55 1.522-.748 2.303l3.815 3.72-.9 5.25c-.19 1.103.968 1.944 1.959 1.424l4.715-2.48 4.716 2.48c.99.52 2.148-.32 1.96-1.424l-.902-5.25 3.816-3.72c.8-.78.359-2.142-.748-2.303l-5.273-.766-2.358-4.777ZM9.74 8.615l2.258-4.576 2.259 4.576a1.35 1.35 0 0 0 1.016.738l5.05.734-3.654 3.562a1.35 1.35 0 0 0-.388 1.195l.862 5.03-4.516-2.375a1.35 1.35 0 0 0-1.257 0l-4.516 2.374.862-5.029a1.35 1.35 0 0 0-.388-1.195l-3.654-3.562 5.05-.734c.44-.063.82-.34 1.016-.738ZM1.164 3.782a.75.75 0 0 0 .118 1.054l2.5 2a.75.75 0 1 0 .937-1.172l-2.5-2a.75.75 0 0 0-1.055.118Z" fill="currentColor"/><path d="M22.836 18.218a.75.75 0 0 0-.117-1.054l-2.5-2a.75.75 0 0 0-.938 1.172l2.5 2a.75.75 0 0 0 1.055-.117ZM1.282 17.164a.75.75 0 1 0 .937 1.172l2.5-2a.75.75 0 0 0-.937-1.172l-2.5 2ZM22.836 3.782a.75.75 0 0 1-.117 1.054l-2.5 2a.75.75 0 0 1-.938-1.172l2.5-2a.75.75 0 0 1 1.055.118Z" fill="currentColor"/></svg>
|
||||
<span class="px-1">Upgrade now</span>
|
||||
</a>
|
||||
<% if ChatwootApp.enterprise? %>
|
||||
<%= render 'upgrade_button_enterprise' %>
|
||||
<% else %>
|
||||
<%= render 'upgrade_button_community' %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center justify-between mb-1.5 mt-4">
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
shared: &shared
|
||||
version: '4.3.0'
|
||||
version: '4.4.0'
|
||||
|
||||
development:
|
||||
<<: *shared
|
||||
|
||||
@@ -183,3 +183,10 @@
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
premium: true
|
||||
chatwoot_internal: true
|
||||
- name: whatsapp_embedded_signup
|
||||
display_name: WhatsApp Embedded Signup
|
||||
enabled: false
|
||||
- name: whatsapp_campaign
|
||||
display_name: WhatsApp Campaign
|
||||
enabled: false
|
||||
|
||||
@@ -434,4 +434,4 @@
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
|
||||
## ------ End of Github Integration Config ------- #
|
||||
## ------ End of Github Integration Config ------- #
|
||||
|
||||
@@ -233,6 +233,10 @@ Rails.application.routes.draw do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
namespace :whatsapp do
|
||||
resource :authorization, only: [:create]
|
||||
end
|
||||
|
||||
resources :webhooks, only: [:index, :create, :update, :destroy]
|
||||
namespace :integrations do
|
||||
resources :apps, only: [:index, :show]
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- action_mailbox_routing
|
||||
- low
|
||||
- scheduled_jobs
|
||||
- deferred
|
||||
- housekeeping
|
||||
- async_database_migration
|
||||
- active_storage_analysis
|
||||
|
||||
@@ -13,4 +13,4 @@ class CreateChannelVoice < ActiveRecord::Migration[7.0]
|
||||
add_index :channel_voice, :phone_number, unique: true
|
||||
add_index :channel_voice, :account_id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
class AddResponseGuidelinesAndGuardrailsToCaptainAssistants < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_assistants, :response_guidelines, :jsonb, default: []
|
||||
add_column :captain_assistants, :guardrails, :jsonb, default: []
|
||||
end
|
||||
end
|
||||
+4
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_10_145708) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_10_145708) do
|
||||
t.jsonb "audience", default: []
|
||||
t.datetime "scheduled_at", precision: nil
|
||||
t.boolean "trigger_only_during_business_hours", default: false
|
||||
t.jsonb "template_params"
|
||||
t.index ["account_id"], name: "index_campaigns_on_account_id"
|
||||
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
|
||||
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
|
||||
@@ -277,6 +278,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_10_145708) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.jsonb "config", default: {}, null: false
|
||||
t.jsonb "response_guidelines", default: []
|
||||
t.jsonb "guardrails", default: []
|
||||
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
|
||||
end
|
||||
|
||||
|
||||
@@ -43,12 +43,19 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def assistant_params
|
||||
params.require(:assistant).permit(:name, :description,
|
||||
config: [
|
||||
:product_name, :feature_faq, :feature_memory,
|
||||
:welcome_message, :handoff_message, :resolution_message,
|
||||
:instructions, :temperature
|
||||
])
|
||||
permitted = params.require(:assistant).permit(:name, :description,
|
||||
config: [
|
||||
:product_name, :feature_faq, :feature_memory,
|
||||
:welcome_message, :handoff_message, :resolution_message,
|
||||
:instructions, :temperature
|
||||
])
|
||||
|
||||
# Handle array parameters separately to allow partial updates
|
||||
permitted[:response_guidelines] = params[:assistant][:response_guidelines] if params[:assistant].key?(:response_guidelines)
|
||||
|
||||
permitted[:guardrails] = params[:assistant][:guardrails] if params[:assistant].key?(:guardrails)
|
||||
|
||||
permitted
|
||||
end
|
||||
|
||||
def playground_params
|
||||
|
||||
@@ -44,4 +44,4 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseC
|
||||
def scenario_params
|
||||
params.require(:scenario).permit(:title, :description, :instruction, :enabled, tools: [])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
#
|
||||
# Table name: captain_assistants
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# guardrails :jsonb
|
||||
# name :string not null
|
||||
# response_guidelines :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -61,4 +61,3 @@ class Channel::Voice < ApplicationRecord
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -18,4 +18,4 @@ class Captain::ScenarioPolicy < ApplicationPolicy
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -57,4 +57,4 @@ class Captain::OpenAiMessageBuilderService
|
||||
result[:success] ? result[:transcriptions] : ''
|
||||
end.join
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -2,4 +2,4 @@ json.data do
|
||||
json.array! @scenarios do |scenario|
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: scenario
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -1 +1 @@
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
json.partial! 'api/v1/models/captain/scenario', scenario: @scenario
|
||||
|
||||
@@ -2,6 +2,8 @@ json.account_id resource.account_id
|
||||
json.config resource.config
|
||||
json.created_at resource.created_at.to_i
|
||||
json.description resource.description
|
||||
json.guardrails resource.guardrails
|
||||
json.id resource.id
|
||||
json.name resource.name
|
||||
json.response_guidelines resource.response_guidelines
|
||||
json.updated_at resource.updated_at.to_i
|
||||
|
||||
@@ -13,4 +13,4 @@ if scenario.assistant.present?
|
||||
json.id scenario.assistant.id
|
||||
json.name scenario.assistant.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,10 +19,14 @@ class ChatwootHub
|
||||
end
|
||||
|
||||
def self.pricing_plan
|
||||
return 'community' unless ChatwootApp.enterprise?
|
||||
|
||||
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
|
||||
end
|
||||
|
||||
def self.pricing_plan_quantity
|
||||
return 0 unless ChatwootApp.enterprise?
|
||||
|
||||
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN_QUANTITY')&.value || 0
|
||||
end
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@chatwoot/chatwoot",
|
||||
"version": "4.3.0",
|
||||
"version": "4.4.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"eslint": "eslint app/**/*.{js,vue}",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 13 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user