diff --git a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb index bb5dab680..0da05e813 100644 --- a/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_csat_templates_controller.rb @@ -24,6 +24,26 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Template parameters are required' }, status: :unprocessable_entity end + def link + link_params = params.require(:template).permit(:name, :language, body_variables: {}) + return render json: { error: 'Template name is required' }, status: :unprocessable_entity if link_params[:name].blank? + + service = CsatTemplateManagementService.new(@inbox) + result = service.link_existing_template( + link_params[:name], link_params[:language], body_variables: link_params[:body_variables].to_h + ) + + render_link_result(result) + rescue ActionController::ParameterMissing + render json: { error: 'Template parameters are required' }, status: :unprocessable_entity + end + + def available_templates + service = CsatTemplateManagementService.new(@inbox) + templates = service.available_templates + render json: { templates: templates } + end + private def fetch_inbox @@ -46,6 +66,22 @@ class Api::V1::Accounts::InboxCsatTemplatesController < Api::V1::Accounts::BaseC render json: { error: 'Message is required' }, status: :unprocessable_entity end + def render_link_result(result) + if result[:success] + render json: { + template: { + name: result[:template_name], template_id: result[:template_id], + status: result[:status], language: result[:language], source: result[:source], + linked_at: result[:linked_at] + } + }, status: :ok + elsif result[:error] + render json: { error: result[:error] }, status: :unprocessable_entity + else + render json: { error: result[:service_error] || 'An unexpected error occurred' }, status: :internal_server_error + end + end + def render_template_creation_result(result) if result[:success] render_successful_template_creation(result) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 8e4ea0146..265debd2f 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -214,7 +214,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController # :lock_to_single_conversation, :portal_id, :sender_name_type, :business_name, { csat_config: [:display_type, :message, :button_text, :language, { survey_rules: [:operator, { values: [] }], - template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, :created_at, :language, :status] }] }] + template: [:name, :template_id, :friendly_name, :content_sid, :approval_sid, + :created_at, :linked_at, :language, :source, :status, { body_variables: {} }] }] }] end def permitted_params(channel_attributes = []) diff --git a/app/drops/conversation_drop.rb b/app/drops/conversation_drop.rb index d62642885..b8f8a9e06 100644 --- a/app/drops/conversation_drop.rb +++ b/app/drops/conversation_drop.rb @@ -24,8 +24,33 @@ class ConversationDrop < BaseDrop custom_attributes.transform_keys(&:to_s) end + def first_reply_created_at + format_datetime(@obj.try(:first_reply_created_at)) + end + + def first_reply_created_at_time + format_datetime(@obj.try(:first_reply_created_at), include_time: true) + end + + def last_activity_at + format_datetime(@obj.try(:last_activity_at)) + end + + def last_activity_at_time + format_datetime(@obj.try(:last_activity_at), include_time: true) + end + private + def format_datetime(datetime, include_time: false) + return '' if datetime.blank? + + locale = @obj.try(:account)&.locale || 'en' + date_format = locale == 'pt_BR' ? '%d/%m/%Y' : '%b %d, %Y' + date_format += ' %H:%M' if include_time + datetime.strftime(date_format) + end + def message_sender_name(sender) return 'Bot' if sender.blank? return contact_name if sender.instance_of?(Contact) diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index f107ddf2d..ef0ec7bf4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -43,6 +43,18 @@ class Inboxes extends CacheEnabledApiClient { return axios.get(`${this.url}/${inboxId}/csat_template`); } + linkCSATTemplate(inboxId, template) { + return axios.post(`${this.url}/${inboxId}/csat_template/link`, { + template, + }); + } + + getAvailableCSATTemplates(inboxId) { + return axios.get( + `${this.url}/${inboxId}/csat_template/available_templates` + ); + } + setupChannelProvider(inboxId) { return axios.post(`${this.url}/${inboxId}/setup_channel_provider`); } diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index a8b564b23..6cbd12e65 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -953,6 +953,32 @@ "SUCCESS_MESSAGE": "WhatsApp template created successfully and sent for approval", "ERROR_MESSAGE": "Failed to create WhatsApp template" }, + "TEMPLATE_MODE": { + "LABEL": "Template source", + "CREATE_NEW": "Create new template", + "USE_EXISTING": "Use existing template" + }, + "EXISTING_TEMPLATE": { + "LABEL": "Select template", + "PLACEHOLDER": "Select an approved template", + "SYNC_BUTTON": "Sync templates", + "SYNCING": "Syncing...", + "EMPTY_STATE": "No compatible templates found. Templates must have a URL button with a dynamic parameter.", + "LINK_SUCCESS": "Template linked successfully", + "LINK_ERROR": "Failed to link template", + "LOADING": "Loading templates...", + "USING_LABEL": "Using existing template: {name}", + "SYNC_ERROR": "Failed to sync templates. Please try again." + }, + "TEMPLATE_VARIABLES": { + "TITLE": "Template variables", + "DESCRIPTION": "Set values for the template body variables. You can use dynamic variables like {'{{'} contact.name {'}}'} that will be resolved at send time.", + "VARIABLE_LABEL": "Variable {variable}", + "VARIABLE_PLACEHOLDER": "Enter value for {variable}", + "VARIABLE_REQUIRED": "This variable is required", + "VALIDATION_ERROR": "Please set all template variables before saving.", + "INSERT_VARIABLE": "Insert variable" + }, "TEMPLATE_UPDATE_DIALOG": { "TITLE": "Edit survey details", "DESCRIPTION": "We will delete the previous template and make a new one which will be sent again for WhatsApp approval", @@ -970,7 +996,7 @@ "SELECT_PLACEHOLDER": "select labels" }, "NOTE": "Note: CSAT surveys are sent only once per conversation", - "WHATSAPP_NOTE": "Note: We will create a template and send it for WhatsApp approval. After being approved, surveys will be sent only once per conversation as per the survey rule.", + "WHATSAPP_NOTE": "Note: You can create a new template (which will be sent for WhatsApp approval) or use an existing approved template from your Meta template manager. Surveys are sent only once per conversation as per the survey rule.", "API": { "SUCCESS_MESSAGE": "CSAT settings updated successfully", "ERROR_MESSAGE": "We couldn't update CSAT settings. Please try again later." diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json index 8eda9f4d3..af22eba1c 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/inboxMgmt.json @@ -906,6 +906,32 @@ "SUCCESS_MESSAGE": "Modelo do WhatsApp criado com sucesso e enviado para aprovação", "ERROR_MESSAGE": "Falha ao criar o modelo do WhatsApp" }, + "TEMPLATE_MODE": { + "LABEL": "Origem do modelo", + "CREATE_NEW": "Criar novo modelo", + "USE_EXISTING": "Usar modelo existente" + }, + "EXISTING_TEMPLATE": { + "LABEL": "Selecionar modelo", + "PLACEHOLDER": "Selecione um modelo aprovado", + "SYNC_BUTTON": "Sincronizar modelos", + "SYNCING": "Sincronizando...", + "EMPTY_STATE": "Nenhum modelo compatível encontrado. Os modelos devem ter um botão de URL com um parâmetro dinâmico.", + "LINK_SUCCESS": "Modelo vinculado com sucesso", + "LINK_ERROR": "Falha ao vincular o modelo", + "LOADING": "Carregando modelos...", + "USING_LABEL": "Usando modelo existente: {name}", + "SYNC_ERROR": "Falha ao sincronizar modelos. Por favor, tente novamente." + }, + "TEMPLATE_VARIABLES": { + "TITLE": "Variáveis do modelo", + "DESCRIPTION": "Defina os valores para as variáveis do corpo do modelo. Você pode usar variáveis dinâmicas como {'{{'} contact.name {'}}'} que serão resolvidas no momento do envio.", + "VARIABLE_LABEL": "Variável {variable}", + "VARIABLE_PLACEHOLDER": "Insira o valor para {variable}", + "VARIABLE_REQUIRED": "Esta variável é obrigatória", + "VALIDATION_ERROR": "Por favor, defina todas as variáveis do modelo antes de salvar.", + "INSERT_VARIABLE": "Inserir variável" + }, "TEMPLATE_UPDATE_DIALOG": { "TITLE": "Editar detalhes da pesquisa", "DESCRIPTION": "Vamos excluir o modelo anterior e criar um que será enviado novamente para aprovação do WhatsApp", @@ -923,7 +949,7 @@ "SELECT_PLACEHOLDER": "selecionar etiquetas" }, "NOTE": "Nota: pesquisas de CSAT são enviadas apenas uma vez por conversa", - "WHATSAPP_NOTE": "Nota: Vamos criar um modelo e enviá-lo para aprovação do WhatsApp. Após a aprovação, as pesquisas serão enviadas apenas uma vez por conversa, conforme a regra da pesquisa.", + "WHATSAPP_NOTE": "Nota: Você pode criar um novo modelo (que será enviado para aprovação do WhatsApp) ou usar um modelo aprovado existente do seu gerenciador de modelos da Meta. As pesquisas são enviadas apenas uma vez por conversa, conforme a regra da pesquisa.", "API": { "SUCCESS_MESSAGE": "Configurações de CSAT atualizadas com sucesso", "ERROR_MESSAGE": "Não foi possível atualizar as configurações do CSAT. Por favor, tente novamente mais tarde." diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue index 18be11ffa..ec549bfbe 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue @@ -15,10 +15,12 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue'; import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import Switch from 'next/switch/Switch.vue'; +import TabBar from 'dashboard/components-next/tabbar/TabBar.vue'; import Input from 'dashboard/components-next/input/Input.vue'; import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; import whatsappTemplateLanguages from './whatsappTemplateLanguages.js'; import ConfirmTemplateUpdateDialog from './components/ConfirmTemplateUpdateDialog.vue'; +import ExistingTemplateSelector from './components/ExistingTemplateSelector.vue'; const props = defineProps({ inbox: { type: Object, required: true }, @@ -53,6 +55,25 @@ const state = reactive({ const templateStatus = ref(null); const templateLoading = ref(false); const confirmDialog = ref(null); +const templateSelectorRef = ref(null); +const templateMode = ref('create_new'); +const selectedExistingTemplateName = ref(''); +const bodyVariables = ref({}); +const existingTemplateBody = ref(''); +const existingTemplateButtonText = ref(''); + +const templateModeTabs = computed(() => [ + { label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.CREATE_NEW'), key: 0 }, + { label: t('INBOX_MGMT.CSAT.TEMPLATE_MODE.USE_EXISTING'), key: 1 }, +]); + +const activeTemplateModeTabIndex = computed(() => + templateMode.value === 'use_existing' ? 1 : 0 +); + +const onTemplateModeTabChange = tab => { + templateMode.value = tab.key === 1 ? 'use_existing' : 'create_new'; +}; const originalTemplateValues = ref({ message: '', @@ -86,14 +107,59 @@ const languageOptions = computed(() => })) ); -const messagePreviewData = computed(() => ({ - content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'), -})); +const resolvedExistingTemplateBody = computed(() => { + if (!existingTemplateBody.value) return ''; + let message = existingTemplateBody.value; + Object.entries(bodyVariables.value).forEach(([key, value]) => { + if (value) { + message = message.replaceAll(`{{${key}}}`, value); + } + }); + return message; +}); + +const messagePreviewData = computed(() => { + if (templateMode.value === 'use_existing') { + return { + content: + resolvedExistingTemplateBody.value || + t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'), + }; + } + return { + content: state.message || t('INBOX_MGMT.CSAT.MESSAGE.PLACEHOLDER'), + }; +}); + +const previewButtonText = computed(() => { + if (templateMode.value === 'use_existing') { + return ( + existingTemplateButtonText.value || + t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER') + ); + } + return state.templateButtonText; +}); const shouldShowTemplateStatus = computed( - () => templateStatus.value && !templateLoading.value + () => + templateStatus.value && + !templateLoading.value && + templateMode.value !== 'use_existing' ); +const isUpdateDisabled = computed(() => { + if ( + templateMode.value === 'use_existing' && + isAWhatsAppCloudChannel.value && + state.csatSurveyEnabled && + !selectedExistingTemplateName.value + ) { + return true; + } + return false; +}); + const templateApprovalStatus = computed(() => { const statusMap = { APPROVED: { @@ -171,6 +237,21 @@ const initializeState = () => { templateButtonText: state.templateButtonText, templateLanguage: state.templateLanguage, }; + + // Set template mode based on stored source + const templateSource = csat_config?.template?.source; + if (templateSource === 'user_selected') { + templateMode.value = 'use_existing'; + selectedExistingTemplateName.value = csat_config.template.name || ''; + bodyVariables.value = csat_config.template.body_variables || {}; + existingTemplateBody.value = message; + existingTemplateButtonText.value = buttonText; + // Reset create-new fields so they don't show stale data + state.message = ''; + state.templateButtonText = 'Please rate us'; + } else { + templateMode.value = 'create_new'; + } } }; @@ -319,32 +400,84 @@ const createTemplate = async () => { return response.template; }; +const linkTemplate = async () => { + if (!selectedExistingTemplateName.value) return null; + + const response = await store.dispatch('inboxes/linkCSATTemplate', { + inboxId: props.inbox.id, + template: { + name: selectedExistingTemplateName.value, + language: state.templateLanguage, + body_variables: bodyVariables.value, + }, + }); + useAlert(t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_SUCCESS')); + return response.template; +}; + +const handleTemplateSelected = template => { + state.templateLanguage = template.language || 'en'; + existingTemplateBody.value = template.body_text || ''; + existingTemplateButtonText.value = + template.button_text || t('INBOX_MGMT.CSAT.BUTTON_TEXT.PLACEHOLDER'); +}; + const performSave = async () => { try { isUpdating.value = true; let newTemplateData = null; - // For WhatsApp channels, create template first if needed - if ( - isTemplateRequiredWhatsAppChannel.value && - state.csatSurveyEnabled && - shouldCreateTemplate() - ) { - try { - newTemplateData = await createTemplate(); - } catch (error) { - const errorMessage = - error.response?.data?.error || - t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE'); - useAlert(errorMessage); - return; + // For WhatsApp channels, handle template based on mode + if (isTemplateRequiredWhatsAppChannel.value && state.csatSurveyEnabled) { + if ( + templateMode.value === 'use_existing' && + isAWhatsAppCloudChannel.value + ) { + // Link existing template mode — require selection + if (!selectedExistingTemplateName.value) return; + + // Validate all body variables are filled + if ( + templateSelectorRef.value && + !templateSelectorRef.value.validate() + ) { + useAlert(t('INBOX_MGMT.CSAT.TEMPLATE_VARIABLES.VALIDATION_ERROR')); + return; + } + + try { + newTemplateData = await linkTemplate(); + } catch (error) { + const errorMessage = + error.response?.data?.error || + t('INBOX_MGMT.CSAT.EXISTING_TEMPLATE.LINK_ERROR'); + useAlert(errorMessage); + return; + } + } else if (shouldCreateTemplate()) { + // Create new template mode + try { + newTemplateData = await createTemplate(); + } catch (error) { + const errorMessage = + error.response?.data?.error || + t('INBOX_MGMT.CSAT.TEMPLATE_CREATION.ERROR_MESSAGE'); + useAlert(errorMessage); + return; + } } } const csatConfig = { display_type: state.displayType, - message: state.message, - button_text: state.templateButtonText, + message: + templateMode.value === 'use_existing' + ? existingTemplateBody.value + : state.message, + button_text: + templateMode.value === 'use_existing' + ? existingTemplateButtonText.value + : state.templateButtonText, language: state.templateLanguage, survey_rules: { operator: state.surveyRuleOperator, @@ -352,15 +485,27 @@ const performSave = async () => { }, }; - // Use new template data if created, otherwise preserve existing template information + // Use new template data if created/linked, otherwise preserve existing template information if (newTemplateData) { - if (isATwilioWhatsAppChannel.value) { + if (newTemplateData.source === 'user_selected') { + // User-selected existing template + csatConfig.template = { + name: newTemplateData.name, + template_id: newTemplateData.template_id, + language: newTemplateData.language, + status: newTemplateData.status, + source: 'user_selected', + linked_at: newTemplateData.linked_at, + body_variables: bodyVariables.value, + }; + } else if (isATwilioWhatsAppChannel.value) { // Twilio WhatsApp template format csatConfig.template = { friendly_name: newTemplateData.friendly_name, content_sid: newTemplateData.content_sid, language: newTemplateData.language, status: newTemplateData.status, + source: 'auto_created', created_at: new Date().toISOString(), }; } else { @@ -370,6 +515,7 @@ const performSave = async () => { template_id: newTemplateData.template_id, language: newTemplateData.language, status: newTemplateData.status, + source: 'auto_created', created_at: new Date().toISOString(), }; } @@ -395,6 +541,12 @@ const performSave = async () => { }; const saveSettings = async () => { + // For "use existing" mode, no confirmation needed — just save + if (templateMode.value === 'use_existing') { + await performSave(); + return; + } + // Check if we need to show confirmation dialog for WhatsApp template changes // This applies to both WhatsApp Cloud and Twilio WhatsApp channels if ( @@ -403,8 +555,12 @@ const saveSettings = async () => { hasExistingTemplate() && hasTemplateChanges() ) { - confirmDialog.value?.open(); - return; + // Only show dialog if the existing template was auto-created (will be deleted) + const existingSource = props.inbox?.csat_config?.template?.source; + if (existingSource !== 'user_selected') { + confirmDialog.value?.open(); + return; + } } await performSave(); @@ -442,40 +598,72 @@ const handleConfirmTemplateUpdate = async () => {