diff --git a/package-lock.json b/package-lock.json index 5b990faa81..646c22e540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21317,6 +21317,50 @@ "node": ">=0.10.0" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snakecase-keys": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-8.0.1.tgz", + "integrity": "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==", + "dependencies": { + "map-obj": "^4.1.0", + "snake-case": "^3.0.4", + "type-fest": "^4.15.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/snakecase-keys/node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/snakecase-keys/node_modules/type-fest": { + "version": "4.32.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.32.0.tgz", + "integrity": "sha512-rfgpoi08xagF3JSdtJlCwMq9DGNDE0IMh3Mkpc1wUypg9vPi786AiqeBBKcqvIkq42azsBM85N490fyZjeUftw==", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sockjs": { "version": "0.3.24", "license": "MIT", @@ -25208,6 +25252,7 @@ "react-json-tree": "^0.18.0", "react-router-dom": "^5.3.4", "react-textarea-autosize": "^8.5.5", + "snakecase-keys": "^8.0.1", "styled-components": "^5.3.11", "ua-parser-js": "^1.0.39", "uuid": "^10.0.0" diff --git a/packages/botonic-core/src/models/legacy-types.ts b/packages/botonic-core/src/models/legacy-types.ts index 45000347a9..8da04bbacc 100644 --- a/packages/botonic-core/src/models/legacy-types.ts +++ b/packages/botonic-core/src/models/legacy-types.ts @@ -77,6 +77,8 @@ export enum INPUT { WHATSAPP_PRODUCT = 'whatsapp-product', WHATSAPP_PRODUCT_LIST = 'whatsapp-product-list', WHATSAPP_PRODUCT_CAROUSEL = 'whatsapp-product-carousel', + WHATSAPP_MEDIA_CAROUSEL = 'whatsapp-media-carousel', + WHATSAPP_ORDER = 'whatsapp_order', } export interface Locales { @@ -119,6 +121,11 @@ export type InputType = | INPUT.WHATSAPP_CTA_URL_BUTTON | INPUT.EVENT_AGENT_MESSAGE_CREATED | INPUT.WHATSAPP_CATALOG + | INPUT.WHATSAPP_PRODUCT + | INPUT.WHATSAPP_PRODUCT_LIST + | INPUT.WHATSAPP_PRODUCT_CAROUSEL + | INPUT.WHATSAPP_MEDIA_CAROUSEL + | INPUT.WHATSAPP_ORDER export interface IntentResult { intent: string @@ -162,6 +169,15 @@ export interface Input extends Partial { type: string data: string } + catalog_id?: string + product_items?: ProductItem[] +} + +interface ProductItem { + product_retailer_id: string + quantity: number + item_price: number + currency: string } export interface Campaign { diff --git a/packages/botonic-react/package.json b/packages/botonic-react/package.json index c88f89d869..c64a458248 100644 --- a/packages/botonic-react/package.json +++ b/packages/botonic-react/package.json @@ -20,7 +20,6 @@ "lint_core": "../../node_modules/.bin/eslint_d --cache --quiet '.*.js' '*.js' 'src/**/*.js*' --fix" }, "dependencies": { - "@babel/helper-simple-access": "^7.25.9", "@botonic/core": "0.31.0-alpha.1", "axios": "^1.7.2", "emoji-picker-react": "^4.12.0", diff --git a/packages/botonic-react/src/components/index.ts b/packages/botonic-react/src/components/index.ts index 2c4555739d..adb1d96c63 100644 --- a/packages/botonic-react/src/components/index.ts +++ b/packages/botonic-react/src/components/index.ts @@ -30,6 +30,10 @@ export { WhatsappCTAUrlButton, WhatsappCTAUrlButtonProps, } from './whatsapp-cta-url-button' +export { + WhatsappMediaCarousel, + WhatsappMediaCarouselProps, +} from './whatsapp-media-carousel' export { WhatsappProduct } from './whatsapp-product' export { WhatsappProductCarousel, diff --git a/packages/botonic-react/src/components/whatsapp-media-carousel.tsx b/packages/botonic-react/src/components/whatsapp-media-carousel.tsx new file mode 100644 index 0000000000..4c0e44f5ab --- /dev/null +++ b/packages/botonic-react/src/components/whatsapp-media-carousel.tsx @@ -0,0 +1,104 @@ +import { INPUT } from '@botonic/core' +import React from 'react' + +import { toSnakeCaseKeys } from '../util/functional' +import { renderComponent } from '../util/react' +import { Message } from './message' + +type Parameters = TextParameter | CurrencyParameter | DateTimeParameter + +interface TextParameter { + type: 'text' + text: string +} + +interface CurrencyParameter { + type: 'currency' + currency: { + fallbackValue: string + code: string + amount1000: number + } +} + +interface DateTimeParameter { + type: 'date_time' + dateTime: { fallbackValue: string } +} + +type CardButton = QuickReplyButton | UrlButton + +interface Button { + type: 'quick_reply' | 'url' + buttonIndex?: number +} + +interface QuickReplyButton extends Button { + payload: string +} + +interface UrlButton extends Button { + urlVariable: string +} + +interface Card { + fileType: 'image' | 'video' + fileId: string + cardIndex?: number + bodyParameters?: Parameters[] + buttons?: CardButton[] + extraComponents?: Record[] +} + +export interface WhatsappMediaCarouselProps { + templateName: string + templateLanguage: string + cards: Card[] + bodyParameters?: Parameters[] +} + +const serialize = (message: string) => { + return { text: message } +} + +export const WhatsappMediaCarousel = (props: WhatsappMediaCarouselProps) => { + const renderBrowser = () => { + // Return a dummy message for browser + const message = `WhatsApp Media Carousel would be sent to the user.` + return ( + + {message} + + ) + } + + const getCards = (cards: Card[]) => { + cards.forEach((card, index) => { + if (!card.cardIndex) { + card.cardIndex = index + } + card.buttons?.forEach((button, index) => { + if (!button.buttonIndex) { + button.buttonIndex = index + } + }) + }) + return toSnakeCaseKeys(cards) + } + + const renderNode = () => { + return ( + // @ts-ignore Property 'message' does not exist on type 'JSX.IntrinsicElements'. + + ) + } + + return renderComponent({ renderBrowser, renderNode }) +} diff --git a/packages/botonic-react/src/components/whatsapp-product-carousel.tsx b/packages/botonic-react/src/components/whatsapp-product-carousel.tsx index 170637c538..743f72740f 100644 --- a/packages/botonic-react/src/components/whatsapp-product-carousel.tsx +++ b/packages/botonic-react/src/components/whatsapp-product-carousel.tsx @@ -1,6 +1,7 @@ import { INPUT } from '@botonic/core' import React from 'react' +import { toSnakeCaseKeys } from '../util/functional' import { renderComponent } from '../util/react' import { Message } from './message' @@ -14,21 +15,21 @@ interface TextParameter { interface CurrencyParameter { type: 'currency' currency: { - fallback_value: string + fallbackValue: string code: string - amount_1000: number + amount1000: number } } interface DateTimeParameter { type: 'date_time' - date_time: { fallback_value: string } + dateTime: { fallbackValue: string } } interface Card { - product_retailer_id: string - catalog_id: string - card_index?: number + productRetailerId: string + catalogId: string + cardIndex?: number } export interface WhatsappProductCarouselProps { @@ -57,11 +58,11 @@ export const WhatsappProductCarousel = ( const getCards = (cards: Card[]) => { cards.forEach((card, index) => { - if (!card.card_index) { - card.card_index = index + if (!card.cardIndex) { + card.cardIndex = index } }) - return cards + return toSnakeCaseKeys(cards) } const renderNode = () => { @@ -69,7 +70,7 @@ export const WhatsappProductCarousel = ( // @ts-ignore Property 'message' does not exist on type 'JSX.IntrinsicElements'. { body={props.body} footer={props.footer} header={props.header} - sections={JSON.stringify(props.sections)} + sections={JSON.stringify(toSnakeCaseKeys(props.sections))} catalogId={props.catalogId} type={INPUT.WHATSAPP_PRODUCT_LIST} /> diff --git a/packages/botonic-react/src/util/functional.ts b/packages/botonic-react/src/util/functional.ts new file mode 100644 index 0000000000..6b0f9888c1 --- /dev/null +++ b/packages/botonic-react/src/util/functional.ts @@ -0,0 +1,31 @@ +function camelCaseToSnake(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1_$2') + .replace(/([A-Za-z])(\d)/g, '$1_$2') + .replace(/(\d)([A-Za-z])/g, '$1_$2') + .toLowerCase() +} +type Input = Record | Record[] | undefined + +export function toSnakeCaseKeys(input: Input): Input { + if (Array.isArray(input)) { + return input.map(item => toSnakeCaseKeys(item)) + } + + if (typeof input === 'object' && input !== null) { + const result = Object.keys(input).reduce((acc, key) => { + const snakeKey = camelCaseToSnake(key) + const value = input[key] + acc[snakeKey] = + typeof value === 'object' && value !== null + ? toSnakeCaseKeys(value) + : value + + return acc + }, {}) + + return result + } + + return input +} diff --git a/packages/botonic-react/tests/components/__snapshots__/whatsapp-media-carousel.test.jsx.snap b/packages/botonic-react/tests/components/__snapshots__/whatsapp-media-carousel.test.jsx.snap new file mode 100644 index 0000000000..adf8fad49c --- /dev/null +++ b/packages/botonic-react/tests/components/__snapshots__/whatsapp-media-carousel.test.jsx.snap @@ -0,0 +1,21 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders Whatsapp Media Carousel component with indexes 1`] = ` + +`; + +exports[`renders Whatsapp Media Carousel component without indexes 1`] = ` + +`; diff --git a/packages/botonic-react/tests/components/whatsapp-media-carousel.test.jsx b/packages/botonic-react/tests/components/whatsapp-media-carousel.test.jsx new file mode 100644 index 0000000000..508eb78b93 --- /dev/null +++ b/packages/botonic-react/tests/components/whatsapp-media-carousel.test.jsx @@ -0,0 +1,76 @@ +import { expect, test } from '@jest/globals' +import React from 'react' +import TestRenderer from 'react-test-renderer' + +import { WhatsappMediaCarousel } from '../../src/components' + +const renderToJSON = sut => TestRenderer.create(sut).toJSON() + +const getProps = withIndexes => { + return { + templateName: 'fake-template-name', + templateLanguage: 'en_US', + cards: [ + { + cardIndex: withIndexes ? 1 : undefined, + fileType: 'image', + fileId: 'fake-file-id-1', + buttons: [ + { + buttonIndex: withIndexes ? 1 : undefined, + type: 'quick_reply', + payload: 'payload-1', + }, + { + buttonIndex: withIndexes ? 0 : undefined, + type: 'url', + urlVariable: 'a', + }, + ], + bodyParameters: [{ type: 'text', text: 'classic' }], + extraComponents: [{}], + }, + { + cardIndex: withIndexes ? 1 : undefined, + fileType: 'image', + fileId: 'fake-file-id-2', + buttons: [ + { + buttonIndex: withIndexes ? 1 : undefined, + type: 'quick_reply', + payload: 'payload-2', + }, + { + buttonIndex: withIndexes ? 0 : undefined, + type: 'url', + urlVariable: 'b', + }, + ], + bodyParameters: [{ type: 'text', text: 'premium' }], + extraComponents: [{}], + }, + ], + bodyParameters: [ + { + type: 'text', + text: 'Pepito Grillo', + }, + { + type: 'text', + text: 'Test', + }, + ], + } +} + +test('renders Whatsapp Media Carousel component without indexes', () => { + const props = getProps(false) + const tree = renderToJSON() + expect(tree).toMatchSnapshot() +}) + +test('renders Whatsapp Media Carousel component with indexes', () => { + const props = getProps(true) + const tree = renderToJSON() + expect(tree).toMatchSnapshot() +}) diff --git a/packages/botonic-react/tests/components/whatsapp-product-carousel.test.jsx b/packages/botonic-react/tests/components/whatsapp-product-carousel.test.jsx index 866d9a262c..f960e64cb9 100644 --- a/packages/botonic-react/tests/components/whatsapp-product-carousel.test.jsx +++ b/packages/botonic-react/tests/components/whatsapp-product-carousel.test.jsx @@ -12,14 +12,14 @@ const getProps = withIndexes => { templateLanguage: 'en_US', cards: [ { - card_index: withIndexes ? 1 : undefined, - catalog_id: 'fake-catalog-id', - product_retailer_id: 'fake-product-id-2', + cardIndex: withIndexes ? 1 : undefined, + catalogId: 'fake-catalog-id', + productRetailerId: 'fake-product-id-2', }, { - card_index: withIndexes ? 0 : undefined, - catalog_id: 'fake-catalog-id', - product_retailer_id: 'fake-product-id-1', + cardIndex: withIndexes ? 0 : undefined, + catalogId: 'fake-catalog-id', + productRetailerId: 'fake-product-id-1', }, ], bodyParameters: [ diff --git a/packages/botonic-react/tests/components/whatsapp-product-list.test.jsx b/packages/botonic-react/tests/components/whatsapp-product-list.test.jsx index 91591bb755..05b22aae6c 100644 --- a/packages/botonic-react/tests/components/whatsapp-product-list.test.jsx +++ b/packages/botonic-react/tests/components/whatsapp-product-list.test.jsx @@ -15,14 +15,14 @@ test('renders Whatsapp Product List component', () => { sections: [ { title: 'section one', - product_items: [ - { product_retailer_id: 'fake-product-id-1' }, - { product_retailer_id: 'fake-product-id-2' }, + productItems: [ + { productRetailerId: 'fake-product-id-1' }, + { productRetailerId: 'fake-product-id-2' }, ], }, { title: 'section two', - product_items: [{ product_retailer_id: 'fake-product-id-3' }], + productItems: [{ productRetailerId: 'fake-product-id-3' }], }, ], } diff --git a/packages/botonic-react/tests/utils.test.js b/packages/botonic-react/tests/utils.test.js index 90aa5a2f77..e061a94158 100644 --- a/packages/botonic-react/tests/utils.test.js +++ b/packages/botonic-react/tests/utils.test.js @@ -2,6 +2,7 @@ * @jest-environment jsdom */ import { isURL, staticAsset } from '../src/util/environment' +import { toSnakeCaseKeys } from '../src/util/functional' import { deserializeRegex, stringifyWithRegexs } from '../src/util/regexs' describe('Regex serialization / deserialization', () => { const regexsItems = [ @@ -126,3 +127,49 @@ describe('staticAsset function', () => { removeScript(script) }) }) + +test('toSnakeCase function converts object keys from camelCase to snake_case', () => { + const obj = { + camelCase: 'value', + anotherCamelCase: 'anotherValue', + } + const expected = { + camel_case: 'value', + another_camel_case: 'anotherValue', + } + expect(toSnakeCaseKeys(obj)).toEqual(expected) +}) + +test('toSnakeCase function converts array of objects keys from camelCase to snake_case', () => { + const obj = { + arrayCamelCase: [ + { camelCase: 'value', anotherCamelCase: 'anotherValue', numberValue: 5 }, + { + camelCase2: 'value', + anotherCamelCase2: 'anotherValue', + anotherArray: [{ camelCase3: 'value' }], + }, + ], + } + const expected = { + array_camel_case: [ + { + camel_case: 'value', + another_camel_case: 'anotherValue', + number_value: 5, + }, + { + camel_case_2: 'value', + another_camel_case_2: 'anotherValue', + another_array: [{ camel_case_3: 'value' }], + }, + ], + } + expect(toSnakeCaseKeys(obj)).toEqual(expected) +}) + +test('toSnakeCase function returns undefined when object is undefined', () => { + const obj = undefined + const expected = undefined + expect(toSnakeCaseKeys(obj)).toEqual(expected) +})