perf: 模板+支持: 换头图, 发券码, 复制码, 按钮url参数和显示; 新模板排序

dev/full-email
Lei OT 5 months ago
parent 51d474bbe5
commit c2f478f6d4

@ -17,6 +17,7 @@ export const fetchTemplates = async (params) => {
'say_hello_again',
'order_updated_specialist_assigned_christy',
'order_resumed_specialist_followup_schedule_sharon',
'travel_service_update_v2',
'travel_service_update_v1',
'order_updated_specialist_assigned_sharon',
'first_message_for_not_reply',
@ -27,9 +28,17 @@ export const fetchTemplates = async (params) => {
const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated'];
// 客运
const crNames = [
// 'notification_of_following_up_by_cr_v3',
'notification_of_one_day_before_ending_the_trip_by_cr_v2',
'post_booking_confirmation_welcome',
'one_day_after_payment_by_yuni',
'notification_of_status_changed',
'notification_of_one_day_before_ending_the_trip_by_cr','one_day_after_payment_by_customer_relations',
'one_day_before_ending_the_trip_contacted_by_yuni','one_day_before_ending_the_trip_first_time_by_yuni',
'post_booking_confirmation_welcome',
];
const crNamesOmit = [
'birthday_greetings_by_marketing','one_day_before_ending_the_trip_by_marketing',
'introduce_the_voucher_one_day_before_ending_the_trip_by_marketing',
'birthday_greetings_by_customer_relations_0',
'post_trip_voucher_issued',
'account_updated_order_ref',
@ -40,13 +49,8 @@ export const fetchTemplates = async (params) => {
'birthday_greetings_by_customer_relations_2',
'birthday_greetings_by_customer_relations_1',
'notification_of_account_updated_by_cr',
'notification_of_one_day_before_ending_the_trip_by_cr',
'birthday_greetings_by_customer_relations',
'one_day_before_ending_the_trip_by_customer_relations',
'one_day_after_payment_by_customer_relations',
'birthday_greetings_by_marketing', 'one_day_before_ending_the_trip_by_marketing',
'one_day_after_payment_by_yuni', 'introduce_the_voucher_one_day_before_ending_the_trip_by_marketing',
'one_day_before_ending_the_trip_contacted_by_yuni', 'one_day_before_ending_the_trip_first_time_by_yuni'
]
const canUseTemplates = (data?.result?.items || [])
.filter((_t) => _t.status === 'APPROVED' && !['say_hello_from_trip_advisor', 'free_style_7', 'free_style_1', 'free_style_2'].includes(_t.name))
@ -57,7 +61,7 @@ export const fetchTemplates = async (params) => {
key: ele.name,
// displayName: ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : templatesDisplayNameMap?.[ele.name] || ele.name,
displayName: templatesDisplayNameMap?.[ele.name] || (ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : ele.name),
displayLanguage: crNames.includes(ele.name) ? ele.language + '-客运' : scNames.includes(ele.name) ? ele.language + '-示例' : ele.language,
displayLanguage: crNamesOmit.includes(ele.name) ? '客运-' : (crNames.includes(ele.name) || ele.name.includes('by_cr')) ? ele.language + '-客运' : scNames.includes(ele.name) ? ele.language + '-示例' : ele.language,
}))
const top2Name = topName.concat(canUseTemplates.filter(_t => _t.name.startsWith('order_updated')).map(_tem => _tem.name));
@ -66,7 +70,9 @@ export const fetchTemplates = async (params) => {
const second = canUseTemplates.filter(_t => _t.name.includes('free_style'));
const secondS = second.sort(sortBy('name'));
const raw = canUseTemplates.filter((_t) => !top2Name.includes(_t.name) && !_t.name.includes('free_style'));
return [...top, ...secondS, ...raw];
// 剩下的排序
const rawS = sortArrayByOrder(raw, 'name', [...crNames, ...scNames, ...crNamesOmit ]);
return [...top, ...secondS, ...rawS];
};
/**
* 上面的模板名称bak

@ -74,6 +74,11 @@ export const replaceTemplateString = (str, replacements) => {
return result;
}
export const whatsappTemplateBtnParamTypesMapped = {
'copy_code': 'coupon_code',
// 'quick_reply': 'payload',
};
/**
* @deprecated 在渲染时处理
*/
@ -226,18 +231,18 @@ export const sentMsgTypeMapped = {
...msg.template,
components: [
...msg.template.components.filter((com) => !['footer', 'buttons'].includes(com.type.toLowerCase())),
...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
? msg.template.components
.filter((com) => 'buttons' === com.type.toLowerCase())[0]
// .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
.buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
.map((btn, btnI) => ({
type: 'button',
sub_type: btn.type.toLowerCase(),
index: btnI,
// parameters: [{ text: 'lq1FTtA8', type: 'text' }]
}))
: []),
// ...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
// ? msg.template.components
// .filter((com) => 'buttons' === com.type.toLowerCase())[0]
// // .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
// .buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
// .map((btn, btnI) => ({
// type: 'button',
// sub_type: btn.type.toLowerCase(),
// index: btnI,
// // parameters: [{ text: 'lq1FTtA8', type: 'text' }]
// }))
// : []),
],
},
}),
@ -697,6 +702,11 @@ export const whatsappMsgTypeMapped = {
data: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.reaction?.emoji || '' }),
},
button: {
type: 'text', // todo: 后端返回 type='button' button: { payload, text }
data: (msg) => ({ id: msg.wamid, text: msg.button?.payload || msg.button?.text || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.button?.payload || msg.button?.text || '' }),
},
document: {
type: 'file',
data: (msg) => ({

@ -1,11 +1,11 @@
import { createContext, useEffect, useState, memo } from 'react';
import { memo } from 'react';
import { App, Button } from 'antd';
import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { ExportOutlined, CopyOutlined, PhoneOutlined } from '@ant-design/icons';
import { MessageBox } from 'react-chat-elements';
import { groupBy, isEmpty } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';
import { WABIcon } from '@/components/Icons';
import { ReplyIcon } from '@/components/Icons';
import ChannelLogo from './ChannelLogo';
const outboundStyle = {
@ -25,10 +25,10 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
const RenderText = memo(function renderText({ str, className, template, message }) {
let headerObj, footerObj, buttonsArr;
if (!isEmpty(template) && !isEmpty(template.components)) {
const componentsObj = groupBy(template.components, (item) => item.type);
const componentsObj = groupBy(template.components.concat(template?.components_omit || []), (item) => item.type);
headerObj = componentsObj?.header?.[0];
footerObj = componentsObj?.footer?.[0];
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
buttonsArr = componentsObj?.button; // ?.reduce((r, c) => r.concat(c.buttons), []);
}
const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation}|\d{4,})/gmu).filter((s) => s !== '');
@ -84,17 +84,17 @@ const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, s
{buttonsArr && buttonsArr.length > 0 ? (
<div className='flex flex-row gap-1'>
{buttonsArr.map((btn, index) =>
btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
btn.sub_type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={`${btn.sub_type}_${btn.index}`} rel='noreferrer' icon={<ExportOutlined />}>
{btn.text}
</Button>
) : btn.type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
) : btn.sub_type.toLowerCase() === 'phone_number' ? (
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer' icon={<PhoneOutlined />}>
{btn.text} ({btn.phone_number})
</Button>
) : (
<Button className='text-blue-500' size={'small'} key={btn.type}>
{btn.text}
<Button className='text-blue-500' size={'small'} key={`${btn.type}_${btn.sub_type}_${btn.index}`} icon={btn.sub_type.toLowerCase() === 'copy_code' ? <CopyOutlined /> : btn.sub_type.toLowerCase() === 'quick_reply' ? <ReplyIcon /> : null}>
{btn.text || btn.sub_type.toUpperCase()}
</Button>
),
)}

@ -3,8 +3,8 @@ import { App, Popover, Flex, Button, List, Input, Tabs, Tag, Alert, Divider } fr
import { MessageOutlined, SendOutlined } from '@ant-design/icons';
import useAuthStore from '@/stores/AuthStore';
import useConversationStore from '@/stores/ConversationStore';
import { cloneDeep, getNestedValue, groupBy, objectMapper, removeFormattingChars, sortArrayByOrder, sortObjectsByKeysMap, TagColorStyle } from '@/utils/commons';
import { replaceTemplateString } from '@/channel/bubbleMsgUtils';
import { cloneDeep, flush, getNestedValue, groupBy, objectMapper, removeFormattingChars, sortArrayByOrder, sortObjectsByKeysMap, TagColorStyle } from '@/utils/commons';
import { replaceTemplateString, whatsappTemplateBtnParamTypesMapped } from '@/channel/bubbleMsgUtils';
import { isEmpty } from '@/utils/commons';
import useStyleStore from '@/stores/StyleStore';
@ -36,16 +36,33 @@ const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, ac
const keys = (templateText.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''))
const paramsVal = keys.reduce((r, k) => ({ ...r, [k]: getNestedValue(valueMapped, [k]) }), {})
if (key === 'header' && tempItem.components?.header?.[0]?.example?.header_url) {
const headerImg = { key: 'header_url', placeholder: '头图地址' };
tempArr.unshift(headerImg);
}
if (key === 'buttons' && (tempItem.components?.buttons?.[0]?.buttons || []).findIndex(btn => btn.type === 'COPY_CODE') !== -1) {
const btnCode = { key: 'copy_code', placeholder: '复制条码' };
tempArr.push(btnCode);
}
if (key === 'buttons' ) {
(tempItem.components?.buttons?.[0]?.buttons || []).filter(btn0 => btn0.type === 'URL').forEach((btn) => {
const hasParam = Object.prototype.hasOwnProperty.call(btn, "example");
const templateUrl = btn.url || ''
const urlParamKeys = (templateUrl.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, '')).map(key => ({ key }))
hasParam ? tempArr.push(...urlParamKeys) : false;
});
}
return (
<>
{tempArr.map((ele) =>
{tempArr.map((ele, i) =>
typeof ele === 'string' ? (
<span key={ele.trim()} className=' text-wrap'>
{ele.replace(/\n+/g, '\n')}
</span>
) : ele.key.includes('free') || ele.key.includes('detail') ? (
<Input.TextArea
key={ele.key}
key={`${ele.key}_${i}`}
rows={2}
onChange={(e) => {
onInput(tempItem, ele.key, e.target.value, paramsVal)
@ -59,14 +76,14 @@ const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, ac
/>
) : (
<Input
key={ele.key}
key={`${ele.key}_${i}`}
onChange={(e) => {
onInput(tempItem, ele.key, e.target.value, paramsVal)
}}
className={ele.key.includes('free') || ele.key.includes('detail') ? `w-full block ` : `w-auto ${paramsVal[ele.key] ? 'max-w-24' : 'max-w-60'}`}
size={'small'}
title={ele.key}
placeholder={`${paramsVal[ele.key] || ele.key} 按Tab键跳到下一个空格`}
placeholder={`${paramsVal[ele.key] || ele.key} ${ele?.placeholder || '按Tab键跳到下一个空格'}`}
value={activeInput[tempItem.name]?.[ele.key] || paramsVal[ele.key] || ''}
// onPressEnter={() => handleSendTemplate(tempItem)}
/>
@ -83,14 +100,19 @@ const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, ac
return (
<div className='pb-1'>
{'text' === headerObj.format.toLowerCase() && <div>{renderForm({ tempItem }, 'header')}</div>}
{'image' === headerObj.format.toLowerCase() && <img src={headerObj.example.header_url} height={100}></img>}
{'image' === headerObj.format.toLowerCase() && (
<div className='flex items-center'>
<img src={headerObj.example.header_url} height={100} className='mr-1'></img>
{renderForm({ tempItem }, 'header')}
</div>
)}
{['document', 'video'].includes(headerObj.format.toLowerCase()) && (
<a href={headerObj.example.header_url} target='_blank' key={headerObj.format} rel='noreferrer' className='text-sm'>
[&nbsp;{headerObj.format}&nbsp;]({headerObj.example.header_url})
</a>
)}
</div>
);
)
}
const renderButtons = ({ tempItem }) => {
if (isEmpty(tempItem.components.buttons)) {
@ -100,7 +122,8 @@ const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, ac
return (
<div className='flex gap-1 pt-1'>
{buttons.map((btn, index) =>
btn.type.toLowerCase() === 'url' ? (
<>
{btn.type.toLowerCase() === 'url' ? (
<Button className='text-blue-500' size={'small'} href={btn.url} target={'_blank'} key={btn.url} rel='noreferrer'>
{btn.text}
</Button>
@ -108,11 +131,18 @@ const CategoryList = ({ dataSource, handleSendTemplate, valueMapped, onInput, ac
<Button className='text-blue-500' size={'small'} key={btn.phone_number} rel='noreferrer'>
{btn.text} ({btn.phone_number})
</Button>
) : (
<Button className='text-blue-500' size={'small'} key={btn.type} rel='noreferrer'>
)
: btn.type.toLowerCase() === 'copy_code' ? null
// (
// <span>{renderForm({ tempItem }, 'buttons')}</span>
// )
: (
<Button className='text-blue-500' size={'small'} key={`${btn.type}_${index}`} rel='noreferrer'>
{btn.text}
</Button>
)
)}
{Object.prototype.hasOwnProperty.call(btn, "example") ? (<span key={`${btn.type}_${index}`}>{renderForm({ tempItem }, 'buttons')}</span>) : null}
</>
)}
</div>
);
@ -213,6 +243,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
const handleSendTemplate = (fromTemplate) => {
const mergeInput = { ...cloneDeep(valueMapped), ...activeInput[fromTemplate.name] };
// console.log('----------------------------------------------', mergeInput)
let valid = true;
const msgObj = {
type: 'whatsappTemplate',
@ -220,29 +251,82 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
template: {
name: fromTemplate.name,
language: { code: fromTemplate.language },
components: sortArrayByOrder(fromTemplate.components_origin.map((citem) => {
components: sortArrayByOrder(fromTemplate.components_origin.reduce((r, citem) => {
const keys = ((citem?.text || '').match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''));
const params = keys.map((v) => ({ type: 'text', text: getNestedValue(mergeInput, [v]) }));
const notTextKeys = [];
const paramNotText = [];
if (citem.type.toLowerCase() === 'header' && (citem?.format || 'text').toLowerCase() !== 'text') {
params[0] = { type: citem.format.toLowerCase(), [citem.format.toLowerCase()]: { link: citem.example.header_url[0] } };
params[0] = { type: citem.format.toLowerCase(), [citem.format.toLowerCase()]: { link: mergeInput?.header_url || citem.example.header_url[0] } };
//
// notTextKeys.push('header_url');
// mergeInput?.header_url ? paramNotText.push(mergeInput?.header_url || '') : false;
}
let buttonsComponents;
if (citem.type.toLowerCase() === 'buttons' ) {
buttonsComponents = citem.buttons.map((btn, i) => {
const hasParam = Object.prototype.hasOwnProperty.call(btn, "example"); // whatsappTemplateBtnParamTypesMapped
let fillBtn = {};
const paramKey = whatsappTemplateBtnParamTypesMapped[btn.type.toLowerCase()];
if (paramKey) {
params[0] = { type: paramKey, [paramKey]: mergeInput?.[btn.type.toLowerCase()] || '' };
notTextKeys.push(paramKey);
mergeInput?.[btn.type.toLowerCase()] ? paramNotText.push(mergeInput?.[btn.type.toLowerCase()] || '') : false;
fillBtn = { text: btn.text || `${btn.type}:${mergeInput?.[btn.type.toLowerCase()] || ''}` };
}
if (btn.type.toLowerCase() === 'url') {
const templateUrl = btn.url || '';
const urlParamKeys = (templateUrl.match(/{{(.*?)}}/g) || []).map((key) => key.replace(/{{|}}/g, ''));
const urlParams = urlParamKeys.map(key => ({ type: 'text', text: getNestedValue(mergeInput, [key]) }))
params.push(...urlParams);
notTextKeys.push(...urlParamKeys);
urlParamKeys.forEach(key => {
if (getNestedValue(mergeInput, [key])) {
paramNotText.push(getNestedValue(mergeInput, [key]));
}
});
fillBtn = hasParam ? { text: btn.text, url: replaceTemplateString(templateUrl, paramNotText) } : {};
}
// if (hasParam) {
// notTextKeys.push(paramKey);
// mergeInput?.[btn.type.toLowerCase()] ? paramNotText.push(mergeInput?.[btn.type.toLowerCase()] || '') : false;
// }
return hasParam ? { type: 'button', index: i, sub_type: btn.type.toLowerCase(), parameters: params, ...fillBtn } : null;
})
buttonsComponents = flush(buttonsComponents);
}
// console.log('******', buttonsComponents, '\n', notTextKeys, '\n', paramNotText)
const paramText = keys.length ? params.map((p) => p.text) : [];
const fillTemplate = paramText.length ? replaceTemplateString(citem?.text || '', paramText) : citem?.text || '';
valid = keys.length !== paramText.filter((s) => s).length ? false : valid;
return ['body', 'header'].includes(citem.type.toLowerCase()) ? {
valid = notTextKeys.length !== paramNotText.filter((s) => s).length ? false : valid;
const _components = ['body', 'header'].includes(citem.type.toLowerCase()) ? [{
type: citem.type.toLowerCase(),
parameters: params,
text: fillTemplate,
} : {...citem, type: citem.type.toLowerCase(),};
}), 'type', ['header', 'body', 'footer', 'buttons'] ),
}] : ['buttons'].includes(citem.type.toLowerCase()) ? buttonsComponents : [{...citem, type: citem.type.toLowerCase(),}];
return r.concat(_components);
}, []), 'type', ['body', 'header', 'footer', 'button', 'buttons'] ),
components_omit: fromTemplate.components_origin.reduce((r, citem) => {
const _componentItems =
citem.type.toLowerCase() === 'buttons'
? citem.buttons.map((btn, index) => ({ ...btn, index, type: 'button', sub_type: btn.type.toLowerCase() }))
: [{ ...citem, type: citem.type.toLowerCase() }]
const staticComponents = _componentItems.filter((item) => !item.example && item.type.toLowerCase() !== 'body')
return r.concat(staticComponents)
}, []),
},
template_origin: fromTemplate,
};
const plainTextMsgObj = {
type: 'text',
text: msgObj.template.components.map((citem) => citem.text).join(''),
text: msgObj.template.components.filter(com => com.type.toLowerCase() === 'body').map((citem) => citem.text).join(''),
};
if (valid !== true) {
notification.warning({
@ -254,6 +338,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
});
return false;
}
// console.log('------------------------------------------------------------------------------', msgObj );
invokeSendMessage(channel === 'waba' ? msgObj : plainTextMsgObj);
setOpenTemplates(false);
setActiveInput({});
@ -267,10 +352,6 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
});
};
return (
<>
<Popover
@ -318,7 +399,7 @@ const InputTemplate = ({ disabled = false, invokeSendMessage, channel }) => {
...(Object.keys(templateLangMapped).map(lang => ({
key: lang, label: lang.toUpperCase(), children: <CategoryList key={'lang-templates-'+lang} dataSource={templateLangMapped[lang]} {...{ handleSendTemplate, activeInput, onInput, valueMapped}} />
})))
]} defaultActiveKey='utility' tabBarExtraContent={{right: <Alert type='info' message={channel==='waba'?'为提升账号质量, 请尽量使用"触达率高"模板': '模板消息将用纯文本发送'} showIcon className='py-0' />, }} size='small' />
]} defaultActiveKey='utility' tabBarExtraContent={{right: <Alert type='info' message={channel==='waba'?'请优先使用"触达率高"模板': '模板消息将用纯文本发送'} showIcon className='py-0' />, }} size='small' />
) :
(
// Search result

Loading…
Cancel
Save