diff --git a/packages/botonic-plugin-flow-builder/CHANGELOG.md b/packages/botonic-plugin-flow-builder/CHANGELOG.md index d0d8960363..b6f0093ff7 100644 --- a/packages/botonic-plugin-flow-builder/CHANGELOG.md +++ b/packages/botonic-plugin-flow-builder/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to Botonic will be documented in this file. ### Added +- [PR-3251](https://github.com/hubtype/botonic/pull/3251): Add support to whatsapp template with flow action buttons. + ### Changed ### Fixed diff --git a/packages/botonic-plugin-flow-builder/src/content-fields/flow-whatsapp-template.tsx b/packages/botonic-plugin-flow-builder/src/content-fields/flow-whatsapp-template.tsx index 1939857e58..ba3822e60a 100644 --- a/packages/botonic-plugin-flow-builder/src/content-fields/flow-whatsapp-template.tsx +++ b/packages/botonic-plugin-flow-builder/src/content-fields/flow-whatsapp-template.tsx @@ -9,6 +9,9 @@ import { type WhatsappTemplateComponentBody, type WhatsappTemplateComponentButtons, type WhatsappTemplateComponentHeader, + type WhatsappTemplateFlowAction, + type WhatsappTemplateFlowButton, + type WhatsappTemplatePhoneNumberButton, type WhatsappTemplateQuickReplyButton, type WhatsappTemplateUrlButton, type WhatsappTemplateVoiceCallButton, @@ -17,14 +20,16 @@ import { import { trackOneContent } from '../tracking' import { getFlowBuilderPlugin } from '../utils/get-flow-builder-plugin' import { ContentFieldsBase } from './content-fields-base' -import type { - HtButton, - HtMediaFileLocale, - HtWhatsAppTemplate, - HtWhatsAppTemplateButtonsComponent, - HtWhatsAppTemplateHeaderComponent, - HtWhatsappTemplateContentByLocale, - HtWhatsappTemplateNode, +import { + type HtButton, + type HtFlowButtonActionValue, + type HtMediaFileLocale, + type HtWhatsAppTemplate, + type HtWhatsAppTemplateButtonsComponent, + HtWhatsAppTemplateFlowActionType, + type HtWhatsAppTemplateHeaderComponent, + type HtWhatsappTemplateContentByLocale, + type HtWhatsappTemplateNode, } from './hubtype-fields' interface HeaderVariables { @@ -39,6 +44,7 @@ export class FlowWhatsappTemplate extends ContentFieldsBase { public headerVariables?: HeaderVariables public buttons?: HtButton[] public urlVariableValues?: Record + public flowButtonActionValues?: Record static fromHubtypeCMS( component: HtWhatsappTemplateNode, @@ -52,10 +58,18 @@ export class FlowWhatsappTemplate extends ContentFieldsBase { currentLocale ) + if (!contentByLocale.template) { + throw new Error( + `Whatsapp template not configured for locale: ${currentLocale}` + ) + } + whatsappTemplate.htWhatsappTemplate = contentByLocale.template whatsappTemplate.headerVariables = contentByLocale.header_variables - whatsappTemplate.variableValues = contentByLocale.variable_values + whatsappTemplate.variableValues = contentByLocale.variable_values || {} whatsappTemplate.urlVariableValues = contentByLocale.url_variable_values + whatsappTemplate.flowButtonActionValues = + contentByLocale.flow_button_action_values whatsappTemplate.followUp = component.follow_up @@ -184,40 +198,86 @@ export class FlowWhatsappTemplate extends ContentFieldsBase { whatsappTemplate: HtWhatsAppTemplate, buttonNodes: HtButton[], urlVariableValues: Record, + flowButtonActionValues: Record, botContext: BotContext ): WhatsappTemplateComponentButtons | undefined { const htWhatsappTemplateButtons = whatsappTemplate.components.find( component => component.type === WhatsAppTemplateComponentType.BUTTONS ) as HtWhatsAppTemplateButtonsComponent | undefined - if (htWhatsappTemplateButtons) { - const buttons = htWhatsappTemplateButtons.buttons - .map((button, index) => { - if (button.type === WhatsAppTemplateButtonSubType.URL) { - const urlParam: string | undefined = - urlVariableValues?.[String(index)] - if (!urlParam) { - return null - } - return this.createUrlButtonComponent(index, urlParam, botContext) - } - - if (button.type === WhatsAppTemplateButtonSubType.QUICK_REPLY) { - const payload = buttonNodes[index].target?.id || '' - return this.createQuickReplyButtonComponent(index, payload) - } - - return this.createVoiceCallButtonComponent(index) - }) - .filter(button => button !== null) - - return { - type: WhatsAppTemplateComponentType.BUTTONS, - buttons: buttons as WhatsappTemplateButton[], + if (!htWhatsappTemplateButtons) { + return undefined + } + + const buttons = htWhatsappTemplateButtons.buttons + .map((button, index) => + this.mapTemplateButton( + button, + index, + whatsappTemplate.name, + buttonNodes, + urlVariableValues, + flowButtonActionValues, + botContext + ) + ) + .filter(button => button !== null) + + return { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: buttons as WhatsappTemplateButton[], + } + } + + private mapTemplateButton( + button: HtWhatsAppTemplateButtonsComponent['buttons'][number], + index: number, + templateName: string, + buttonNodes: HtButton[], + urlVariableValues: Record, + flowButtonActionValues: Record, + botContext: BotContext + ): WhatsappTemplateButton | null { + if (button.type === WhatsAppTemplateButtonSubType.URL) { + const urlParam: string | undefined = urlVariableValues?.[String(index)] + if (!urlParam) { + return null } + return this.createUrlButtonComponent(index, urlParam, botContext) } - return undefined + if (button.type === WhatsAppTemplateButtonSubType.QUICK_REPLY) { + const payload = buttonNodes[index].target?.id || '' + return this.createQuickReplyButtonComponent(index, payload) + } + + if (button.type === WhatsAppTemplateButtonSubType.PHONE_NUMBER) { + return this.createPhoneNumberButtonComponent(index) + } + + if (button.type === WhatsAppTemplateButtonSubType.VOICE_CALL) { + return this.createVoiceCallButtonComponent(index) + } + + if (button.type === WhatsAppTemplateButtonSubType.FLOW) { + const actionValue = flowButtonActionValues?.[String(index)] + if (!actionValue) { + throw new Error( + `WhatsApp template '${templateName}' FLOW button at index ${index} requires flow_button_action_values` + ) + } + return this.createFlowButtonComponent( + index, + button, + actionValue, + botContext, + templateName + ) + } + + throw new Error( + `WhatsApp template '${templateName}' has unsupported button at index ${index}` + ) } private createUrlButtonComponent( @@ -269,6 +329,82 @@ export class FlowWhatsappTemplate extends ContentFieldsBase { } } + private createPhoneNumberButtonComponent( + index: number + ): WhatsappTemplatePhoneNumberButton { + return { + type: WhatsAppTemplateComponentType.BUTTON, + sub_type: WhatsAppTemplateButtonSubType.PHONE_NUMBER, + index: index, + parameters: [], + } + } + + private resolveFlowActionData( + flowActionData: Record | undefined, + botContext: BotContext + ): Record | undefined { + if (!flowActionData) { + return undefined + } + + const resolvedData = Object.fromEntries( + Object.entries(flowActionData).map(([key, value]) => [ + key, + this.replaceVariables(value, botContext), + ]) + ) + + return Object.keys(resolvedData).length > 0 ? resolvedData : undefined + } + + private createFlowButtonComponent( + index: number, + templateButton: Extract< + HtWhatsAppTemplateButtonsComponent['buttons'][number], + { type: WhatsAppTemplateButtonSubType.FLOW } + >, + actionValue: HtFlowButtonActionValue, + botContext: BotContext, + templateName: string + ): WhatsappTemplateFlowButton { + const flowToken = this.replaceVariables(actionValue.flow_token, botContext) + if (!flowToken.trim()) { + throw new Error( + `WhatsApp template '${templateName}' FLOW button at index ${index} requires a non-empty flow_token` + ) + } + + const action: WhatsappTemplateFlowAction = { + flow_token: flowToken, + } + + if ( + templateButton.flow_action !== + HtWhatsAppTemplateFlowActionType.DATA_EXCHANGE + ) { + const flowActionData = this.resolveFlowActionData( + actionValue.flow_action_data, + botContext + ) + if (flowActionData) { + action.flow_action_data = flowActionData + } + } + + return { + type: WhatsAppTemplateComponentType.BUTTON, + sub_type: WhatsAppTemplateButtonSubType.FLOW, + index: String(index), + parameters: [ + { + type: WhatsAppTemplateParameterType.ACTION, + action, + }, + ], + } + } + async trackFlow(botContext: BotContext): Promise { await trackOneContent(botContext, this) } @@ -296,6 +432,7 @@ export class FlowWhatsappTemplate extends ContentFieldsBase { this.htWhatsappTemplate, this.buttons || [], this.urlVariableValues || {}, + this.flowButtonActionValues || {}, botContext ) diff --git a/packages/botonic-plugin-flow-builder/src/content-fields/hubtype-fields/whatsapp-template.ts b/packages/botonic-plugin-flow-builder/src/content-fields/hubtype-fields/whatsapp-template.ts index 1e17901ebb..d6673de5d2 100644 --- a/packages/botonic-plugin-flow-builder/src/content-fields/hubtype-fields/whatsapp-template.ts +++ b/packages/botonic-plugin-flow-builder/src/content-fields/hubtype-fields/whatsapp-template.ts @@ -8,6 +8,11 @@ import type { HtButton } from './button' import type { HtBaseNode, HtMediaFileLocale } from './common' import type { HtNodeWithContentType } from './node-types' +export enum HtWhatsAppTemplateFlowActionType { + NAVIGATE = 'navigate', + DATA_EXCHANGE = 'data_exchange', +} + type HtWhatsAppTemplateButton = | { type: WhatsAppTemplateButtonSubType.URL @@ -27,6 +32,24 @@ type HtWhatsAppTemplateButton = phone_number: string index: number } + | { + type: WhatsAppTemplateButtonSubType.VOICE_CALL + text: string + index: number + } + | { + type: WhatsAppTemplateButtonSubType.FLOW + text: string + flow_id: string + flow_action?: HtWhatsAppTemplateFlowActionType + navigate_screen?: string + index: number + } + +export interface HtFlowButtonActionValue { + flow_token: string + flow_action_data?: Record +} export interface HtWhatsAppTemplateHeaderComponent { type: WhatsAppTemplateComponentType.HEADER @@ -71,14 +94,15 @@ export interface HtWhatsAppTemplate { } export interface HtWhatsappTemplateContentByLocale { - template: HtWhatsAppTemplate + template?: HtWhatsAppTemplate header_variables?: { type: WhatsAppTemplateParameterType text?: Record media?: HtMediaFileLocale[] } - variable_values: Record + variable_values?: Record url_variable_values?: Record + flow_button_action_values?: Record } export interface HtWhatsappTemplateNode extends HtBaseNode { diff --git a/packages/botonic-plugin-flow-builder/tests/helpers/flows/whatsapp-template.ts b/packages/botonic-plugin-flow-builder/tests/helpers/flows/whatsapp-template.ts index f4bb5a12f6..020bd0ec6a 100644 --- a/packages/botonic-plugin-flow-builder/tests/helpers/flows/whatsapp-template.ts +++ b/packages/botonic-plugin-flow-builder/tests/helpers/flows/whatsapp-template.ts @@ -18,6 +18,7 @@ const WhatsAppTemplateButtonSubType = { URL: 'URL', QUICK_REPLY: 'QUICK_REPLY', PHONE_NUMBER: 'PHONE_NUMBER', + FLOW: 'FLOW', } as const export const whatsappTemplateFlow = { @@ -496,6 +497,134 @@ export const whatsappTemplateFlow = { has_queue_position_changed_notifications_enabled: false, }, }, + { + id: 'keyword-whatsapp-template-flow-button', + code: '', + is_code_ai_generated: false, + meta: { x: 0, y: 500 }, + follow_up: null, + target: { + id: 'whatsapp-template-flow-button-node', + type: 'whatsapp-template', + }, + flow_id: 'main-flow', + is_meaningful: false, + ai_translated_locales: [], + type: 'keyword', + content: { + title: [], + keywords: [{ values: ['templateFlowButton'], locale: 'en' }], + }, + }, + { + id: 'whatsapp-template-flow-button-node', + code: 'WHATSAPP_TEMPLATE_FLOW_BUTTON', + is_code_ai_generated: false, + meta: { x: 300, y: 500 }, + follow_up: null, + target: null, + flow_id: 'main-flow', + is_meaningful: false, + ai_translated_locales: [], + type: 'whatsapp-template', + content: { + by_locale: { + en: { + template: { + id: 'template-flow-button-id', + name: 'booking_flow_template', + language: 'en', + status: 'APPROVED', + category: 'UTILITY', + components: [ + { + type: WhatsAppTemplateComponentType.BODY, + text: 'Book your appointment', + }, + { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateButtonSubType.URL, + text: 'Visit Website', + url: 'https://example.com/booking/{{1}}', + index: 0, + }, + { + type: WhatsAppTemplateButtonSubType.QUICK_REPLY, + text: 'Talk to Agent', + id: 'quick-reply-agent', + index: 1, + }, + { + type: WhatsAppTemplateButtonSubType.PHONE_NUMBER, + text: 'Call Support', + phone_number: '+1234567890', + index: 2, + }, + { + type: WhatsAppTemplateButtonSubType.FLOW, + text: 'Open Flow', + flow_id: 'meta-flow-id-123', + flow_action: 'navigate', + navigate_screen: 'BOOKING_SCREEN', + index: 3, + }, + ], + }, + ], + namespace: 'test-namespace', + parameter_format: 'POSITIONAL', + }, + variable_values: {}, + url_variable_values: { + '0': '{ticketId}', + }, + flow_button_action_values: { + '3': { + flow_token: 'static-booking-token', + flow_action_data: { + ticket_id: '{ticketId}', + }, + }, + }, + }, + }, + buttons: [ + { + id: 'url-button-id', + text: [{ message: 'Visit Website', locale: 'en' }], + url: [], + target: null, + hidden: [], + }, + { + id: 'quick-reply-button-id', + text: [{ message: 'Talk to Agent', locale: 'en' }], + url: [], + target: { + id: 'agent-handoff-node', + type: 'handoff', + }, + hidden: [], + }, + { + id: 'phone-button-id', + text: [{ message: 'Call Support', locale: 'en' }], + url: [], + target: null, + hidden: [], + }, + { + id: 'flow-button-id', + text: [{ message: 'Open Flow', locale: 'en' }], + url: [], + target: null, + hidden: [], + }, + ], + }, + }, // Keyword to trigger WhatsApp template without header { id: 'keyword-whatsapp-template-no-header', diff --git a/packages/botonic-plugin-flow-builder/tests/messages/flow-whatsapp-template.test.ts b/packages/botonic-plugin-flow-builder/tests/messages/flow-whatsapp-template.test.ts index fbcf397cfd..8daacf5235 100644 --- a/packages/botonic-plugin-flow-builder/tests/messages/flow-whatsapp-template.test.ts +++ b/packages/botonic-plugin-flow-builder/tests/messages/flow-whatsapp-template.test.ts @@ -3,9 +3,11 @@ import { WhatsAppTemplateButtonSubType, WhatsAppTemplateComponentType, WhatsAppTemplateParameterType, + type WhatsappTemplateFlowButton, type WhatsappTemplateHeaderImageParameter, type WhatsappTemplateHeaderTextParameter, type WhatsappTemplateHeaderVideoParameter, + type WhatsappTemplatePhoneNumberButton, type WhatsappTemplateQuickReplyButton, type WhatsappTemplateUrlButton, } from '@botonic/react' @@ -14,6 +16,7 @@ import { describe, expect, test } from '@jest/globals' import { FlowWhatsappTemplate } from '../../src/content-fields/flow-whatsapp-template' import { HtNodeWithContentType } from '../../src/content-fields/hubtype-fields/node-types' import type { HtWhatsappTemplateNode } from '../../src/content-fields/hubtype-fields/whatsapp-template' +import { HtWhatsAppTemplateFlowActionType } from '../../src/content-fields/hubtype-fields/whatsapp-template' import { ProcessEnvNodeEnvs } from '../../src/types' import { whatsappTemplateFlow } from '../helpers/flows/whatsapp-template' import { createFlowBuilderPluginAndGetContents } from '../helpers/utils' @@ -166,6 +169,31 @@ describe('FlowWhatsappTemplate', () => { expect(flowWhatsappTemplate.headerVariables).toBeUndefined() expect(flowWhatsappTemplate.urlVariableValues).toBeUndefined() }) + + test('should throw when template is not configured for locale', () => { + const mockNode: HtWhatsappTemplateNode = { + id: 'test-node-id', + code: 'TEST_TEMPLATE', + meta: { x: 0, y: 0 }, + follow_up: undefined, + target: undefined, + flow_id: 'test-flow', + is_meaningful: false, + type: HtNodeWithContentType.WHATSAPP_TEMPLATE, + content: { + by_locale: { + en: { + variable_values: {}, + }, + }, + buttons: [], + }, + } + + expect(() => FlowWhatsappTemplate.fromHubtypeCMS(mockNode, 'en')).toThrow( + 'Whatsapp template not configured for locale: en' + ) + }) }) describe('Integration tests with flow builder', () => { @@ -417,6 +445,7 @@ describe('FlowWhatsappTemplate', () => { template.htWhatsappTemplate, template.buttons!, template.urlVariableValues!, + template.flowButtonActionValues || {}, request ) @@ -442,12 +471,355 @@ describe('FlowWhatsappTemplate', () => { .payload ).toBe('agent-handoff-node') - // Voice Call button (maps to default since PHONE_NUMBER is not handled separately) - const voiceCallButton = buttons?.buttons[2] - expect(voiceCallButton?.sub_type).toBe( - WhatsAppTemplateButtonSubType.VOICE_CALL + // Phone Number button + const phoneNumberButton = buttons?.buttons[2] + expect(phoneNumberButton?.sub_type).toBe( + WhatsAppTemplateButtonSubType.PHONE_NUMBER + ) + expect(phoneNumberButton?.index).toBe(2) + expect( + (phoneNumberButton as WhatsappTemplatePhoneNumberButton)?.parameters + ).toEqual([]) + }) + + test('should create FLOW button with flow_token and flow_action_data variable replacement', async () => { + const { contents, request } = await createFlowBuilderPluginAndGetContents( + { + flowBuilderOptions: { flow: whatsappTemplateFlow }, + requestArgs: { + input: { data: 'templateFlowButton', type: INPUT.TEXT }, + extraData: { ticketId: 'TKT-FLOW-99' }, + }, + } + ) + + const template = contents[0] as FlowWhatsappTemplate + expect(template.flowButtonActionValues).toEqual({ + '3': { + flow_token: 'static-booking-token', + flow_action_data: { + ticket_id: '{ticketId}', + }, + }, + }) + + // @ts-expect-error - accessing private method for testing + const buttons = template.getButtons( + template.htWhatsappTemplate, + template.buttons!, + template.urlVariableValues || {}, + template.flowButtonActionValues || {}, + request + ) + + expect(buttons?.buttons).toHaveLength(4) + + const flowButton = buttons?.buttons[3] as WhatsappTemplateFlowButton + expect(flowButton.sub_type).toBe(WhatsAppTemplateButtonSubType.FLOW) + expect(flowButton.index).toBe('3') + expect(flowButton.parameters[0].type).toBe( + WhatsAppTemplateParameterType.ACTION + ) + expect(flowButton.parameters[0].action).toEqual({ + flow_token: 'static-booking-token', + flow_action_data: { + ticket_id: 'TKT-FLOW-99', + }, + }) + }) + + test('should create FLOW button without flow_action_data when optional', async () => { + const mockNode: HtWhatsappTemplateNode = { + id: 'test-flow-node-id', + code: 'TEST_FLOW_TEMPLATE', + meta: { x: 0, y: 0 }, + follow_up: undefined, + target: undefined, + flow_id: 'test-flow', + is_meaningful: false, + type: HtNodeWithContentType.WHATSAPP_TEMPLATE, + content: { + by_locale: { + en: { + template: { + id: 'template-id', + name: 'navigate_only_template', + language: 'en', + status: 'APPROVED', + category: 'UTILITY', + components: [ + { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateButtonSubType.FLOW, + text: 'Open Flow', + flow_id: 'meta-flow-id', + flow_action: HtWhatsAppTemplateFlowActionType.NAVIGATE, + index: 0, + }, + ], + }, + ], + namespace: 'test-namespace', + parameter_format: 'POSITIONAL', + }, + variable_values: {}, + flow_button_action_values: { + '0': { + flow_token: 'token-only', + }, + }, + }, + }, + buttons: [], + }, + } + + const { request } = await createFlowBuilderPluginAndGetContents({ + flowBuilderOptions: { flow: whatsappTemplateFlow }, + requestArgs: { + input: { data: 'templateNoHeader', type: INPUT.TEXT }, + }, + }) + + const template = FlowWhatsappTemplate.fromHubtypeCMS(mockNode, 'en') + // @ts-expect-error - accessing private method for testing + const buttons = template.getButtons( + template.htWhatsappTemplate, + template.buttons || [], + {}, + template.flowButtonActionValues || {}, + request + ) + + const flowButton = buttons?.buttons[0] as WhatsappTemplateFlowButton + expect(flowButton.index).toBe('0') + expect(flowButton.parameters[0].action).toEqual({ + flow_token: 'token-only', + }) + }) + + test('should omit flow_action_data for data_exchange template buttons', async () => { + const mockNode: HtWhatsappTemplateNode = { + id: 'test-flow-node-id', + code: 'TEST_FLOW_TEMPLATE', + meta: { x: 0, y: 0 }, + follow_up: undefined, + target: undefined, + flow_id: 'test-flow', + is_meaningful: false, + type: HtNodeWithContentType.WHATSAPP_TEMPLATE, + content: { + by_locale: { + en: { + template: { + id: 'template-id', + name: 'data_exchange_template', + language: 'en', + status: 'APPROVED', + category: 'UTILITY', + components: [ + { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateButtonSubType.FLOW, + text: 'Open Flow', + flow_id: 'meta-flow-id', + flow_action: + HtWhatsAppTemplateFlowActionType.DATA_EXCHANGE, + index: 0, + }, + ], + }, + ], + namespace: 'test-namespace', + parameter_format: 'POSITIONAL', + }, + variable_values: {}, + flow_button_action_values: { + '0': { + flow_token: 'static-data-token', + flow_action_data: { + customer_id: 'should-be-omitted', + }, + }, + }, + }, + }, + buttons: [ + { + id: 'flow-btn', + text: [{ message: 'Open Flow', locale: 'en' }], + url: [], + target: undefined, + hidden: [], + }, + ], + }, + } + + const { request } = await createFlowBuilderPluginAndGetContents({ + flowBuilderOptions: { flow: whatsappTemplateFlow }, + requestArgs: { + input: { data: 'templateNoHeader', type: INPUT.TEXT }, + }, + }) + + const template = FlowWhatsappTemplate.fromHubtypeCMS(mockNode, 'en') + // @ts-expect-error - accessing private method for testing + const buttons = template.getButtons( + template.htWhatsappTemplate, + template.buttons!, + {}, + template.flowButtonActionValues || {}, + request + ) + + const flowButton = buttons?.buttons[0] as WhatsappTemplateFlowButton + expect(flowButton.parameters[0].action).toEqual({ + flow_token: 'static-data-token', + }) + }) + + test('should throw when FLOW button is missing flow_button_action_values', async () => { + const mockNode: HtWhatsappTemplateNode = { + id: 'test-flow-node-id', + code: 'TEST_FLOW_TEMPLATE', + meta: { x: 0, y: 0 }, + follow_up: undefined, + target: undefined, + flow_id: 'test-flow', + is_meaningful: false, + type: HtNodeWithContentType.WHATSAPP_TEMPLATE, + content: { + by_locale: { + en: { + template: { + id: 'template-id', + name: 'missing_flow_values', + language: 'en', + status: 'APPROVED', + category: 'UTILITY', + components: [ + { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateButtonSubType.FLOW, + text: 'Open Flow', + flow_id: 'meta-flow-id', + flow_action: + HtWhatsAppTemplateFlowActionType.DATA_EXCHANGE, + index: 0, + }, + ], + }, + ], + namespace: 'test-namespace', + parameter_format: 'POSITIONAL', + }, + variable_values: {}, + }, + }, + buttons: [], + }, + } + + const { request } = await createFlowBuilderPluginAndGetContents({ + flowBuilderOptions: { flow: whatsappTemplateFlow }, + requestArgs: { + input: { data: 'templateNoHeader', type: INPUT.TEXT }, + }, + }) + + const template = FlowWhatsappTemplate.fromHubtypeCMS(mockNode, 'en') + + expect(() => + // @ts-expect-error - accessing private method for testing + template.getButtons( + template.htWhatsappTemplate, + template.buttons || [], + {}, + {}, + request + ) + ).toThrow( + "WhatsApp template 'missing_flow_values' FLOW button at index 0 requires flow_button_action_values" + ) + }) + + test('should throw when FLOW button has empty flow_token', async () => { + const mockNode: HtWhatsappTemplateNode = { + id: 'test-flow-node-id', + code: 'TEST_FLOW_TEMPLATE', + meta: { x: 0, y: 0 }, + follow_up: undefined, + target: undefined, + flow_id: 'test-flow', + is_meaningful: false, + type: HtNodeWithContentType.WHATSAPP_TEMPLATE, + content: { + by_locale: { + en: { + template: { + id: 'template-id', + name: 'empty_flow_token', + language: 'en', + status: 'APPROVED', + category: 'UTILITY', + components: [ + { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateButtonSubType.FLOW, + text: 'Open Flow', + flow_id: 'meta-flow-id', + flow_action: + HtWhatsAppTemplateFlowActionType.DATA_EXCHANGE, + index: 0, + }, + ], + }, + ], + namespace: 'test-namespace', + parameter_format: 'POSITIONAL', + }, + variable_values: {}, + flow_button_action_values: { + '0': { + flow_token: ' ', + }, + }, + }, + }, + buttons: [], + }, + } + + const { request } = await createFlowBuilderPluginAndGetContents({ + flowBuilderOptions: { flow: whatsappTemplateFlow }, + requestArgs: { + input: { data: 'templateNoHeader', type: INPUT.TEXT }, + }, + }) + + const template = FlowWhatsappTemplate.fromHubtypeCMS(mockNode, 'en') + + expect(() => + // @ts-expect-error - accessing private method for testing + template.getButtons( + template.htWhatsappTemplate, + template.buttons || [], + {}, + template.flowButtonActionValues || {}, + request + ) + ).toThrow( + "WhatsApp template 'empty_flow_token' FLOW button at index 0 requires a non-empty flow_token" ) - expect(voiceCallButton?.index).toBe(2) }) test('should filter out URL buttons without urlParam', async () => { @@ -527,6 +899,7 @@ describe('FlowWhatsappTemplate', () => { template.htWhatsappTemplate, template.buttons!, template.urlVariableValues || {}, + template.flowButtonActionValues || {}, request ) @@ -553,6 +926,7 @@ describe('FlowWhatsappTemplate', () => { template.htWhatsappTemplate, [], {}, + {}, request ) diff --git a/packages/botonic-react/CHANGELOG.md b/packages/botonic-react/CHANGELOG.md index e75c4265fe..a871b7f977 100644 --- a/packages/botonic-react/CHANGELOG.md +++ b/packages/botonic-react/CHANGELOG.md @@ -14,6 +14,8 @@ All notable changes to Botonic will be documented in this file. ### Added +- [PR-3251](https://github.com/hubtype/botonic/pull/3251): Add support to whatsapp template with flow action buttons. + ### Changed ### Fixed diff --git a/packages/botonic-react/src/components/whatsapp-template/index.tsx b/packages/botonic-react/src/components/whatsapp-template/index.tsx index 831298bd1a..43c31e845a 100644 --- a/packages/botonic-react/src/components/whatsapp-template/index.tsx +++ b/packages/botonic-react/src/components/whatsapp-template/index.tsx @@ -2,6 +2,12 @@ import { INPUT } from '@botonic/core' import { renderComponent } from '../../util/react' import { Message } from '../message' +import type { + WhatsappTemplateComponentBody, + WhatsappTemplateComponentButtons, + WhatsappTemplateComponentFooter, + WhatsappTemplateComponentHeader, +} from './types' const serialize = (message: string) => { return { text: message } @@ -11,10 +17,10 @@ export interface WhatsappTemplateProps { name: string language: string namespace?: string - header?: Record - body?: Record - footer?: Record - buttons?: Record + header?: WhatsappTemplateComponentHeader + body?: WhatsappTemplateComponentBody + footer?: WhatsappTemplateComponentFooter + buttons?: WhatsappTemplateComponentButtons } export const WhatsappTemplate = (props: WhatsappTemplateProps) => { diff --git a/packages/botonic-react/src/components/whatsapp-template/types.ts b/packages/botonic-react/src/components/whatsapp-template/types.ts index 81759d1173..ec338753d4 100644 --- a/packages/botonic-react/src/components/whatsapp-template/types.ts +++ b/packages/botonic-react/src/components/whatsapp-template/types.ts @@ -3,6 +3,7 @@ export enum WhatsAppTemplateButtonSubType { QUICK_REPLY = 'QUICK_REPLY', PHONE_NUMBER = 'PHONE_NUMBER', VOICE_CALL = 'VOICE_CALL', + FLOW = 'FLOW', } export enum WhatsAppTemplateParameterType { @@ -11,6 +12,7 @@ export enum WhatsAppTemplateParameterType { IMAGE = 'IMAGE', VIDEO = 'VIDEO', DOCUMENT = 'DOCUMENT', + ACTION = 'ACTION', } export enum WhatsAppTemplateComponentType { @@ -102,7 +104,33 @@ export interface WhatsappTemplateVoiceCallButton { parameters: [] } +export interface WhatsappTemplatePhoneNumberButton { + type: WhatsAppTemplateComponentType.BUTTON + sub_type: WhatsAppTemplateButtonSubType.PHONE_NUMBER + index: number + parameters: [] +} + +export interface WhatsappTemplateFlowAction { + flow_token: string + flow_action_data?: Record +} + +export interface WhatsappTemplateFlowActionParameter { + type: WhatsAppTemplateParameterType.ACTION + action: WhatsappTemplateFlowAction +} + +export interface WhatsappTemplateFlowButton { + type: WhatsAppTemplateComponentType.BUTTON + sub_type: WhatsAppTemplateButtonSubType.FLOW + index: string + parameters: WhatsappTemplateFlowActionParameter[] +} + export type WhatsappTemplateButton = | WhatsappTemplateQuickReplyButton | WhatsappTemplateUrlButton | WhatsappTemplateVoiceCallButton + | WhatsappTemplatePhoneNumberButton + | WhatsappTemplateFlowButton diff --git a/packages/botonic-react/tests/components/whatsapp-template.test.tsx b/packages/botonic-react/tests/components/whatsapp-template.test.tsx index 18d41d8c4f..57bba94352 100644 --- a/packages/botonic-react/tests/components/whatsapp-template.test.tsx +++ b/packages/botonic-react/tests/components/whatsapp-template.test.tsx @@ -161,6 +161,65 @@ describe('WhatsappTemplate Component', () => { expect(tree).toMatchSnapshot() }) + test('renders WhatsappTemplate with FLOW button', () => { + const props = { + name: 'booking_flow', + language: 'en', + buttons: { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateComponentType.BUTTON, + sub_type: WhatsAppTemplateButtonSubType.FLOW, + index: '0', + parameters: [ + { + type: WhatsAppTemplateParameterType.ACTION, + action: { + flow_token: 'static-booking-token', + flow_action_data: { + ticket_id: 'TKT-001', + }, + }, + }, + ], + }, + ], + }, + } + + const tree = renderToJSON() + expect(tree).toMatchSnapshot() + }) + + test('renders WhatsappTemplate with FLOW button without flow_action_data', () => { + const props = { + name: 'data_exchange_flow', + language: 'en', + buttons: { + type: WhatsAppTemplateComponentType.BUTTONS, + buttons: [ + { + type: WhatsAppTemplateComponentType.BUTTON, + sub_type: WhatsAppTemplateButtonSubType.FLOW, + index: '0', + parameters: [ + { + type: WhatsAppTemplateParameterType.ACTION, + action: { + flow_token: 'static-data-token', + }, + }, + ], + }, + ], + }, + } + + const tree = renderToJSON() + expect(tree).toMatchSnapshot() + }) + test('renders WhatsappTemplate with all components (header, body, buttons)', () => { const props = { name: 'support_ticket', @@ -230,6 +289,7 @@ describe('WhatsApp Template Types', () => { expect(WhatsAppTemplateButtonSubType.QUICK_REPLY).toBe('QUICK_REPLY') expect(WhatsAppTemplateButtonSubType.PHONE_NUMBER).toBe('PHONE_NUMBER') expect(WhatsAppTemplateButtonSubType.VOICE_CALL).toBe('VOICE_CALL') + expect(WhatsAppTemplateButtonSubType.FLOW).toBe('FLOW') }) test('WhatsAppTemplateParameterType has correct values', () => { @@ -238,6 +298,7 @@ describe('WhatsApp Template Types', () => { expect(WhatsAppTemplateParameterType.IMAGE).toBe('IMAGE') expect(WhatsAppTemplateParameterType.VIDEO).toBe('VIDEO') expect(WhatsAppTemplateParameterType.DOCUMENT).toBe('DOCUMENT') + expect(WhatsAppTemplateParameterType.ACTION).toBe('ACTION') }) test('WhatsAppTemplateComponentType has correct values', () => {