mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
feat: final ui modifications for in-app messages
This commit is contained in:
parent
e46367e1a1
commit
ff04913d51
29 changed files with 512 additions and 405 deletions
|
@ -12,6 +12,7 @@ type BannerNotification = BaseNotification & { type: 'banner' }
|
|||
|
||||
interface StyledNotification extends BaseNotification {
|
||||
html: string
|
||||
read_on_show?: boolean
|
||||
}
|
||||
|
||||
interface AlertNotification extends StyledNotification {
|
||||
|
|
|
@ -2,18 +2,28 @@ import { createNotification } from '../../notifications/NotificationService'
|
|||
import { Variables } from '../../render'
|
||||
import { InAppTemplate, PushTemplate } from '../../render/Template'
|
||||
import { PushDevice } from '../../users/Device'
|
||||
import { loadPushChannel } from '../push'
|
||||
import PushChannel from '../push/PushChannel'
|
||||
|
||||
export default class InAppChannel {
|
||||
readonly pushChannel: PushChannel
|
||||
constructor(pushChannel: PushChannel) {
|
||||
if (pushChannel) {
|
||||
this.pushChannel = pushChannel
|
||||
} else {
|
||||
throw new Error('A valid push notification provider must be defined!')
|
||||
}
|
||||
}
|
||||
|
||||
async send(template: InAppTemplate, devices: PushDevice[], variables: Variables) {
|
||||
const content = template.compile(variables)
|
||||
await createNotification(variables.user, content)
|
||||
|
||||
const channel = await loadPushChannel(template.provider_id, variables.project.id)
|
||||
return await channel?.send(PushTemplate.fromJson({
|
||||
title: template.content.title,
|
||||
body: template.content.body,
|
||||
silent: true,
|
||||
return await this.pushChannel.send(PushTemplate.fromJson({
|
||||
data: {
|
||||
title: template.content.title,
|
||||
body: template.content.body,
|
||||
silent: true,
|
||||
},
|
||||
}), devices, variables)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { loadInAppChannel } from '.'
|
||||
import App from '../../app'
|
||||
import { updateSendState } from '../../campaigns/CampaignService'
|
||||
import { releaseLock } from '../../core/Lock'
|
||||
import { EventPostJob } from '../../jobs'
|
||||
import Job, { EncodedJob } from '../../queue/Job'
|
||||
import { InAppTemplate } from '../../render/Template'
|
||||
import { getPushDevicesForUser } from '../../users/DeviceRepository'
|
||||
import { disableNotifications } from '../../users/UserRepository'
|
||||
import { MessageTrigger } from '../MessageTrigger'
|
||||
import { finalizeSend, loadSendJob, messageLock, prepareSend } from '../MessageTriggerService'
|
||||
import { failSend, finalizeSend, loadSendJob, messageLock, prepareSend } from '../MessageTriggerService'
|
||||
import PushError from '../push/PushError'
|
||||
import PushJob from '../push/PushJob'
|
||||
|
||||
export default class InAppJob extends Job {
|
||||
static $name = 'in_app_job'
|
||||
|
@ -22,30 +22,57 @@ export default class InAppJob extends Job {
|
|||
const data = await loadSendJob<InAppTemplate>(trigger)
|
||||
if (!data) return
|
||||
|
||||
const { campaign, template, user, project } = data
|
||||
const { campaign, template, user, project, context } = data
|
||||
const devices = await getPushDevicesForUser(project.id, user.id)
|
||||
|
||||
// Load in-app channel so it's ready to send
|
||||
const channel = await loadInAppChannel(campaign.provider_id, project.id)
|
||||
if (!channel) {
|
||||
await updateSendState({
|
||||
campaign,
|
||||
user,
|
||||
reference_id: trigger.reference_id,
|
||||
state: 'aborted',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isReady = await prepareSend(channel, data, raw)
|
||||
if (!isReady) return
|
||||
|
||||
try {
|
||||
// Load in-app channel so it's ready to send
|
||||
const channel = await loadInAppChannel()
|
||||
const isReady = await prepareSend(channel, data, raw)
|
||||
if (!isReady) return
|
||||
|
||||
// Send the in-app message and update the send record
|
||||
const result = await channel.send(template, devices, data)
|
||||
if (result) {
|
||||
await finalizeSend(data, result)
|
||||
|
||||
// A user may have multiple devices some of which
|
||||
// may have failed even though the push was
|
||||
// successful. We need to check for those and
|
||||
// disable them
|
||||
if (result.invalidTokens.length) await disableNotifications(user, result.invalidTokens)
|
||||
}
|
||||
await finalizeSend(data, result)
|
||||
|
||||
// A user may have multiple devices some of which
|
||||
// may have failed even though the push was
|
||||
// successful. We need to check for those and
|
||||
// disable them
|
||||
if (result.invalidTokens.length) await disableNotifications(user, result.invalidTokens)
|
||||
} catch (error: any) {
|
||||
error instanceof PushError
|
||||
? await PushJob.handlePushFailed(error, trigger, data)
|
||||
: App.main.error.notify(error)
|
||||
await failSend(data, error, (error: any) => !(error instanceof PushError))
|
||||
|
||||
if (error instanceof PushError) {
|
||||
|
||||
// If the push is unable to send, find invalidated tokens
|
||||
// and disable those devices
|
||||
await disableNotifications(user, error.invalidTokens)
|
||||
|
||||
// Create an event about the disabling
|
||||
await EventPostJob.from({
|
||||
project_id: project.id,
|
||||
user_id: user.id,
|
||||
event: {
|
||||
name: 'notifications_disabled',
|
||||
external_id: user.external_id,
|
||||
data: {
|
||||
...context,
|
||||
tokens: error.invalidTokens,
|
||||
},
|
||||
},
|
||||
}).queue()
|
||||
}
|
||||
} finally {
|
||||
await releaseLock(messageLock(campaign, user))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
import { loadPushChannel } from '../push'
|
||||
import InAppChannel from './InAppChannel'
|
||||
|
||||
export const loadInAppChannel = () => {
|
||||
return new InAppChannel()
|
||||
export const loadInAppChannel = async (providerId: number, projectId: number) => {
|
||||
const channel = await loadPushChannel(providerId, projectId)
|
||||
if (!channel) return
|
||||
return new InAppChannel(channel)
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { BaseNotification } from '../../notifications/Notification'
|
||||
|
||||
export interface BasePush extends BaseNotification {
|
||||
topic: string
|
||||
silent: boolean
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ import { PushTemplate } from '../../render/Template'
|
|||
import { getPushDevicesForUser } from '../../users/DeviceRepository'
|
||||
import { disableNotifications } from '../../users/UserRepository'
|
||||
import { MessageTrigger } from '../MessageTrigger'
|
||||
import { failSend, finalizeSend, loadSendJob, MessageContextHydrated, messageLock, prepareSend } from '../MessageTriggerService'
|
||||
import { failSend, finalizeSend, loadSendJob, messageLock, prepareSend } from '../MessageTriggerService'
|
||||
import PushError from './PushError'
|
||||
|
||||
export default class PushJob extends Job {
|
||||
|
@ -77,35 +77,4 @@ export default class PushJob extends Job {
|
|||
await releaseLock(messageLock(campaign, user))
|
||||
}
|
||||
}
|
||||
|
||||
static async handlePushFailed(error: PushError, trigger: MessageTrigger, data: MessageContextHydrated) {
|
||||
|
||||
const { campaign, user, project, context } = data
|
||||
|
||||
// If the push is unable to send, find invalidated tokens
|
||||
// and disable those devices
|
||||
await disableNotifications(user, error.invalidTokens)
|
||||
|
||||
// Update send record
|
||||
await updateSendState({
|
||||
campaign,
|
||||
user,
|
||||
reference_id: trigger.reference_id,
|
||||
state: 'failed',
|
||||
})
|
||||
|
||||
// Create an event about the disabling
|
||||
await EventPostJob.from({
|
||||
project_id: project.id,
|
||||
user_id: user.id,
|
||||
event: {
|
||||
name: 'notifications_disabled',
|
||||
external_id: user.external_id,
|
||||
data: {
|
||||
...context,
|
||||
tokens: error.invalidTokens,
|
||||
},
|
||||
},
|
||||
}).queue()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -191,7 +191,10 @@ export class PushTemplate extends Template {
|
|||
title: Render(this.title, variables),
|
||||
body: Render(this.body, variables),
|
||||
silent: this.silent,
|
||||
custom: { ...custom, url },
|
||||
custom: {
|
||||
...custom,
|
||||
...url ? { url } : {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -287,19 +290,29 @@ export class WebhookTemplate extends Template {
|
|||
|
||||
export class InAppTemplate extends Template {
|
||||
declare type: 'in_app'
|
||||
provider_id!: number
|
||||
content!: NotificationContent
|
||||
|
||||
parseJson(json: any) {
|
||||
super.parseJson(json)
|
||||
|
||||
const { provider_id, ...content } = json?.data
|
||||
this.provider_id = provider_id
|
||||
this.content = content
|
||||
this.content = json?.data
|
||||
}
|
||||
|
||||
compile(variables: Variables): NotificationContent {
|
||||
return RenderObject(this.content, variables) as NotificationContent
|
||||
const base = {
|
||||
title: Render(this.content.title, variables),
|
||||
body: Render(this.content.body, variables),
|
||||
custom: RenderObject(this.content.custom, variables),
|
||||
}
|
||||
|
||||
if (this.content.type === 'banner') {
|
||||
return { ...base, type: 'banner' }
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
html: Render(this.content.html, variables),
|
||||
type: this.content.type,
|
||||
}
|
||||
}
|
||||
|
||||
validate() {
|
||||
|
@ -307,14 +320,11 @@ export class InAppTemplate extends Template {
|
|||
type: 'object',
|
||||
required: ['type', 'title', 'body'],
|
||||
properties: {
|
||||
type: { type: 'string' },
|
||||
read_on_show: { type: 'boolean' },
|
||||
title: { type: 'string' },
|
||||
body: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
errorMessage: {
|
||||
required: this.requiredErrors('type', 'title', 'body'),
|
||||
},
|
||||
}, this.data)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -123,7 +123,6 @@ const templateDataWebhookParams = {
|
|||
|
||||
const templateDataInAppParams = {
|
||||
type: 'object',
|
||||
required: ['html'],
|
||||
properties: {
|
||||
html: { type: 'string' },
|
||||
custom: {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"admin": "Admin",
|
||||
"admins": "Admins",
|
||||
"advanced": "Advanced",
|
||||
"alert": "Alert",
|
||||
"api_keys": "API Keys",
|
||||
"api_triggered": "API Triggered",
|
||||
"archive": "Archive",
|
||||
|
@ -174,6 +175,7 @@
|
|||
"hour": "Hour",
|
||||
"hour_one": "{{count}} Hour",
|
||||
"hour_other": "{{count}} Hours",
|
||||
"html": "HTML",
|
||||
"images": "Images",
|
||||
"image_upload": "Click or drag file in to upload a new image. Note, files are uploaded at their original resolution and filesize.",
|
||||
"image_url": "Enter an external URL for use for your image.",
|
||||
|
|
|
@ -473,6 +473,7 @@ export type InAppTemplateData = {
|
|||
body: string
|
||||
custom: Record<string, string | number>
|
||||
type: NotificationType
|
||||
read_on_view?: boolean
|
||||
} & (
|
||||
| {
|
||||
type: 'alert'
|
||||
|
|
|
@ -170,24 +170,30 @@
|
|||
}
|
||||
|
||||
.in-app-frame {
|
||||
background: #010101;
|
||||
max-width: 375px;
|
||||
min-height: max(100%, 600px);
|
||||
width: 125%;
|
||||
transform: scale(0.8);
|
||||
transform-origin: center center;
|
||||
width: 375px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
border: 1px solid #1b1721;
|
||||
border-radius: 45px;
|
||||
box-shadow: inset 0 0 4px 2px #c0b7cd,inset 0 0 0 6px #342c3f;
|
||||
aspect-ratio: 19.5 / 9;
|
||||
.in-app-frame-phone {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
aspect-ratio: 9 / 19.5;
|
||||
}
|
||||
|
||||
.in-app-frame iframe {
|
||||
width: 125%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: scale(0.8);
|
||||
transform-origin: center center;
|
||||
background: linear-gradient(45deg, rgba(2,0,36,1) 0%, rgba(9,9,121,1) 35%, rgba(0,212,255,1) 100%);
|
||||
border-radius: 32px;
|
||||
border: 1px solid #1b1721;
|
||||
border-radius: 45px;
|
||||
box-shadow: inset 0 0 4px 2px #c0b7cd,inset 0 0 0 6px #342c3f;
|
||||
}
|
||||
|
||||
.preview.small .email-frame iframe {
|
||||
|
@ -225,4 +231,20 @@
|
|||
transform: scale(0.8);
|
||||
transform-origin: top left;
|
||||
width: 125%;
|
||||
}
|
||||
|
||||
.preview.small .in-app-frame {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.preview.small .in-app-frame-phone {
|
||||
width: 125%;
|
||||
transform: scale(0.8);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.preview.small .in-app-frame iframe {
|
||||
width: 125%;
|
||||
transform: scale(0.8);
|
||||
transform-origin: top center;
|
||||
}
|
|
@ -73,8 +73,10 @@ export default function Preview({ template, response, size = 'large' }: PreviewP
|
|||
)
|
||||
} else if (type === 'in_app') {
|
||||
preview = (
|
||||
<div className="phone-frame in-app-frame">
|
||||
<Iframe content={data.html ?? ''} />
|
||||
<div className="in-app-frame">
|
||||
<div className="in-app-frame-phone">
|
||||
<Iframe content={data.html ?? ''} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -66,6 +66,16 @@ export const PushIcon = () => <svg xmlns="http://www.w3.org/2000/svg" fill="none
|
|||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
|
||||
export const InAppIcon = () => <svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24">
|
||||
<g transform="translate(6 1)" stroke="none" strokeWidth={1} fill="none" fillRule="evenodd">
|
||||
<path
|
||||
d="M4.5.5H2.25A2.25 2.25 0 000 2.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0012 19.25V2.75A2.25 2.25 0 009.75.5H7.5m-3 0V2h3V.5m-3 0h3"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<rect fill="currentColor" x={2} y={6} width={8} height={13} rx={1} />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
export const WebhookIcon = () => <svg height="24" viewBox="0 0 32 32" width="24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" className="icon">
|
||||
<path d="m16 3c-3.855469 0-7 3.144531-7 7 0 2.09375 1.035156 3.871094 2.5 5.15625l-2.09375 3.875c-.136719-.019531-.265625-.03125-.40625-.03125-1.644531 0-3 1.355469-3 3s1.355469 3 3 3 3-1.355469 3-3c0-.796875-.328125-1.523437-.84375-2.0625l2.84375-5.28125-.75-.5c-1.347656-.894531-2.25-2.410156-2.25-4.15625 0-2.773437 2.226563-5 5-5 2.773438 0 5 2.226563 5 5 0 .585938-.097656 1.136719-.28125 1.65625l1.875.6875c.261719-.730469.40625-1.527344.40625-2.34375 0-3.855469-3.144531-7-7-7zm0 4c-1.644531 0-3 1.355469-3 3s1.355469 3 3 3c.140625 0 .269531-.011719.40625-.03125l2.75 4.375.5.8125.84375-.5c.734375-.425781 1.585938-.65625 2.5-.65625 2.773438 0 5 2.226563 5 5 0 2.773438-2.226562 5-5 5-1.488281 0-2.804687-.632812-3.71875-1.65625l-1.5 1.3125c1.28125 1.429688 3.152344 2.34375 5.21875 2.34375 3.855469 0 7-3.144531 7-7s-3.144531-7-7-7c-.921875 0-1.722656.363281-2.53125.6875l-2.28125-3.65625c.5-.535156.8125-1.25.8125-2.03125 0-1.644531-1.355469-3-3-3zm0 2c.5625 0 1 .4375 1 1s-.4375 1-1 1-1-.4375-1-1 .4375-1 1-1zm-8.15625 6.09375c-.800781.136719-1.601562.414063-2.34375.84375-3.335937 1.925781-4.488281 6.226563-2.5625 9.5625 1.925781 3.335938 6.222656 4.488281 9.5625 2.5625 1.910156-1.105469 2.941406-3.03125 3.25-5.0625h4.4375c.417969 1.15625 1.519531 2 2.8125 2 1.644531 0 3-1.355469 3-3s-1.355469-3-3-3c-1.292969 0-2.394531.84375-2.8125 2h-6.1875v1c0 1.726563-.890625 3.414063-2.5 4.34375-2.402344 1.386719-5.457031.558594-6.84375-1.84375s-.558594-5.457031 1.84375-6.84375c.53125-.308594 1.085938-.496094 1.65625-.59375zm1.15625 5.90625c.5625 0 1 .4375 1 1s-.4375 1-1 1-1-.4375-1-1 .4375-1 1-1zm14 0c.5625 0 1 .4375 1 1s-.4375 1-1 1-1-.4375-1-1 .4375-1 1-1z"/>
|
||||
</svg>
|
||||
|
|
|
@ -5,7 +5,7 @@ import Alert from '../../ui/Alert'
|
|||
import Button from '../../ui/Button'
|
||||
import Heading from '../../ui/Heading'
|
||||
import LocaleSelector from './locale/LocaleSelector'
|
||||
import TemplateDetail from './TemplateDetail'
|
||||
import TemplateDetail from './template/TemplateDetail'
|
||||
import VariantSelector from './variants/VariantSelector'
|
||||
|
||||
export default function CampaignDesign() {
|
||||
|
|
|
@ -11,7 +11,7 @@ import LaunchCampaign from './launch/LaunchCampaign'
|
|||
import { ArchiveIcon, DuplicateIcon, ForbiddenIcon, RestartIcon, SendIcon } from '../../ui/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Menu, MenuItem } from '../../ui'
|
||||
import { TemplateContextProvider } from './TemplateContextProvider'
|
||||
import { TemplateContextProvider } from './template/TemplateContextProvider'
|
||||
|
||||
export default function CampaignDetail() {
|
||||
const [project] = useContext(ProjectContext)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ChannelType } from '../../types'
|
||||
import { EmailIcon, PushIcon, TextIcon, WebhookIcon } from '../../ui/icons'
|
||||
import { EmailIcon, InAppIcon, PushIcon, TextIcon, WebhookIcon } from '../../ui/icons'
|
||||
import Tag, { TagProps } from '../../ui/Tag'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
@ -9,13 +9,14 @@ interface ChannelTagParams {
|
|||
}
|
||||
|
||||
export function ChannelIcon({ channel }: Pick<ChannelTagParams, 'channel'>) {
|
||||
const Icon = channel === 'email'
|
||||
? EmailIcon
|
||||
: channel === 'text'
|
||||
? TextIcon
|
||||
: channel === 'push' || channel === 'in_app'
|
||||
? PushIcon
|
||||
: WebhookIcon
|
||||
const icons = {
|
||||
email: EmailIcon,
|
||||
text: TextIcon,
|
||||
push: PushIcon,
|
||||
webhook: WebhookIcon,
|
||||
in_app: InAppIcon,
|
||||
}
|
||||
const Icon = icons[channel]
|
||||
return <Icon />
|
||||
}
|
||||
|
||||
|
|
|
@ -1,291 +0,0 @@
|
|||
import { useContext, useState } from 'react'
|
||||
import { CampaignContext, ProjectContext, TemplateContext } from '../../contexts'
|
||||
import Button, { LinkButton } from '../../ui/Button'
|
||||
import { Column, Columns } from '../../ui/Columns'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import Heading from '../../ui/Heading'
|
||||
import Preview from '../../ui/Preview'
|
||||
import { InfoTable } from '../../ui/InfoTable'
|
||||
import Modal from '../../ui/Modal'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import { EmailTemplateData, InAppTemplateData, PushTemplateData, Template, TemplateUpdateParams, TextTemplateData, WebhookTemplateData } from '../../types'
|
||||
import TextInput from '../../ui/form/TextInput'
|
||||
import api from '../../api'
|
||||
import { SingleSelect } from '../../ui/form/SingleSelect'
|
||||
import JsonField from '../../ui/form/JsonField'
|
||||
import { Alert, Tag } from '../../ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const EmailTable = ({ data }: { data: EmailTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentTemplate, variants } = useContext(TemplateContext)
|
||||
const validate = (field: string, value: string | undefined, required = true) => {
|
||||
if (!value && required) return <Tag variant="warn">{t('missing')}</Tag>
|
||||
if (['cc', 'bcc', 'reply_to', 'from_email'].includes(field) && value && !value.includes('@')) {
|
||||
return <Tag variant="warn">{t('invalid_email')}: "{value}"</Tag>
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
return <>
|
||||
<InfoTable rows={{
|
||||
...variants.length ? { [t('variant')]: currentTemplate?.name } : {},
|
||||
[t('from_email')]: validate('from_email', data.from?.address),
|
||||
[t('from_name')]: validate('from_name', data.from?.name),
|
||||
[t('reply_to')]: validate('reply_to', data.reply_to, false),
|
||||
[t('cc')]: validate('cc', data.cc, false),
|
||||
[t('bcc')]: validate('bcc', data.bcc, false),
|
||||
[t('subject')]: validate('subject', data.subject),
|
||||
[t('preheader')]: data.preheader,
|
||||
}} />
|
||||
</>
|
||||
}
|
||||
|
||||
const EmailForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<TextInput.Field form={form} name="data.from.name" label={t('from_name')} required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.from.address"
|
||||
label={t('from_email')}
|
||||
type="email"
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.subject"
|
||||
label={t('subject')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.preheader"
|
||||
label={t('preheader')}
|
||||
textarea />
|
||||
<TextInput.Field form={form} name="data.reply_to" label={t('reply_to')} />
|
||||
<TextInput.Field form={form} name="data.cc" label={t('cc')} />
|
||||
<TextInput.Field form={form} name="data.bcc" label={t('bcc')} />
|
||||
</>
|
||||
}
|
||||
|
||||
const TextTable = ({ data: { text } }: { data: TextTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
const [project] = useContext(ProjectContext)
|
||||
const segmentLength = 160
|
||||
const optOutLength = project.text_opt_out_message?.length ?? 0
|
||||
const baseLength = (text?.length ?? 0)
|
||||
const totalLength = baseLength + optOutLength
|
||||
const isHandlebars = text?.includes('{{') ?? false
|
||||
|
||||
const lengthStr = (length: number) => {
|
||||
const segments = Math.ceil(length / segmentLength)
|
||||
return `${isHandlebars ? '~' : ''}${length}/${segmentLength} characters, ${segments} segment${segments > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return <>
|
||||
<InfoTable rows={{
|
||||
Text: text ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
}} />
|
||||
<Heading title="Send Details" size="h4" />
|
||||
{baseLength > segmentLength && <Alert variant="plain" title="Note" body={`Carriers calculate your send rate as segments per second not messages per second. This campaign will take approximately ${Math.ceil(baseLength / segmentLength)}x longer to send due to its length.`} />}
|
||||
<InfoTable rows={{
|
||||
'Existing User Length': lengthStr(baseLength),
|
||||
'New User Length': lengthStr(totalLength),
|
||||
}} />
|
||||
</>
|
||||
}
|
||||
|
||||
const TextForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <TextInput.Field
|
||||
form={form}
|
||||
name="data.text"
|
||||
label={t('message')}
|
||||
textarea
|
||||
required />
|
||||
}
|
||||
|
||||
const PushTable = ({ data }: { data: PushTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
return <InfoTable rows={{
|
||||
[t('title')]: data.title ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('body')]: data.body ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('deeplink')]: data.url,
|
||||
[t('raw_json')]: JSON.stringify(data.custom),
|
||||
}} />
|
||||
}
|
||||
|
||||
const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.title"
|
||||
label={t('title')}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.body"
|
||||
label={t('body')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.url"
|
||||
label={t('deeplink')} />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.custom"
|
||||
label={t('raw_json')}
|
||||
textarea />
|
||||
</>
|
||||
}
|
||||
|
||||
const WebhookTable = ({ data }: { data: WebhookTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
return <InfoTable rows={{
|
||||
[t('method')]: data.method ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('endpoint')]: data.endpoint ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('headers')]: JSON.stringify(data.headers),
|
||||
[t('body')]: JSON.stringify(data.body),
|
||||
[t('cache_key')]: data.cache_key,
|
||||
}} />
|
||||
}
|
||||
|
||||
const WebhookForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<SingleSelect.Field
|
||||
form={form}
|
||||
name="data.method"
|
||||
label={t('method')}
|
||||
options={['DELETE', 'GET', 'PATCH', 'POST', 'PUT']}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.endpoint"
|
||||
label={t('endpoint')}
|
||||
required />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.headers"
|
||||
label={t('headers')}
|
||||
textarea />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.body"
|
||||
label={t('body')}
|
||||
textarea />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.cache_key"
|
||||
label={t('cache_key')}
|
||||
subtitle={t('cache_key_subtitle')} />
|
||||
</>
|
||||
}
|
||||
|
||||
const InAppTable = ({ data }: { data: InAppTemplateData }) => <InfoTable rows={{
|
||||
Type: data.type,
|
||||
Title: data.title,
|
||||
Body: data.body,
|
||||
Custom: JSON.stringify(data.custom),
|
||||
}} />
|
||||
|
||||
const InAppForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => <>
|
||||
<SingleSelect.Field
|
||||
form={form}
|
||||
name="data.type"
|
||||
label="Method"
|
||||
options={['alert', 'html']}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.title"
|
||||
label="Title"
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.body"
|
||||
label="Body"
|
||||
textarea />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.custom"
|
||||
label="Custom"
|
||||
textarea />
|
||||
</>
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: Template
|
||||
}
|
||||
|
||||
export default function TemplateDetail({ template }: TemplateDetailProps) {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [{ id, type, data }, setTemplate] = useState(template)
|
||||
const [campaign, setCampaign] = useContext(CampaignContext)
|
||||
const [project] = useContext(ProjectContext)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const showCodeEditor = type === 'email' || type === 'in_app'
|
||||
|
||||
async function handleTemplateSave(params: TemplateUpdateParams) {
|
||||
const value = await api.templates.update(project.id, id, params)
|
||||
setTemplate(value)
|
||||
|
||||
const newCampaign = { ...campaign }
|
||||
newCampaign.templates = campaign.templates.map(obj => obj.id === id ? value : obj)
|
||||
setCampaign(newCampaign)
|
||||
setIsEditOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Columns>
|
||||
<Column>
|
||||
<Heading title={t('details')} size="h4" actions={
|
||||
campaign.state !== 'finished' && <Button size="small" variant="secondary" onClick={() => { setIsEditOpen(true) }}>{t('edit_details')}</Button>
|
||||
} />
|
||||
{
|
||||
{
|
||||
email: <EmailTable data={data} />,
|
||||
text: <TextTable data={data} />,
|
||||
push: <PushTable data={data} />,
|
||||
webhook: <WebhookTable data={data} />,
|
||||
in_app: <InAppTable data={data} />,
|
||||
}[type]
|
||||
}
|
||||
</Column>
|
||||
|
||||
<Column fullscreen={true}>
|
||||
<Heading title={t('design')} size="h4" actions={
|
||||
showCodeEditor && campaign.state !== 'finished' && <LinkButton size="small" variant="secondary" to={`../editor?template=${template.id}`}>{t('edit_design')}</LinkButton>
|
||||
} />
|
||||
<Preview template={{ type, data }} />
|
||||
</Column>
|
||||
</Columns>
|
||||
|
||||
<Modal title={t('edit_template_details')}
|
||||
open={isEditOpen}
|
||||
onClose={() => setIsEditOpen(false)}
|
||||
>
|
||||
<FormWrapper<TemplateUpdateParams>
|
||||
onSubmit={handleTemplateSave}
|
||||
defaultValues={{ data }}
|
||||
submitLabel="Save"
|
||||
>
|
||||
{form => <>
|
||||
{
|
||||
{
|
||||
email: <EmailForm form={form} />,
|
||||
text: <TextForm form={form} />,
|
||||
push: <PushForm form={form} />,
|
||||
webhook: <WebhookForm form={form} />,
|
||||
in_app: <InAppForm form={form} />,
|
||||
}[type]
|
||||
}
|
||||
</>}
|
||||
</FormWrapper>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -12,7 +12,7 @@ import { toast } from 'react-hot-toast/headless'
|
|||
import { QuestionIcon } from '../../../ui/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ResourceModal from './ResourceModal'
|
||||
import { TemplateContextProvider } from '../TemplateContextProvider'
|
||||
import { TemplateContextProvider } from '../template/TemplateContextProvider'
|
||||
import VariantSelector from '../variants/VariantSelector'
|
||||
|
||||
const VisualEditor = lazy(async () => await import('./VisualEditor'))
|
||||
|
|
|
@ -13,7 +13,7 @@ import { useNavigate } from 'react-router'
|
|||
import DateTimeField from './DateTimeField'
|
||||
import { Button, InfoTable } from '../../../ui'
|
||||
import { snakeToTitle } from '../../../utils'
|
||||
import { localeOption } from '../TemplateContextProvider'
|
||||
import { localeOption } from '../template/TemplateContextProvider'
|
||||
import { DelimitedLists } from '../ui/DelimitedItems'
|
||||
import { UseFormReturn, useWatch } from 'react-hook-form'
|
||||
|
||||
|
@ -41,7 +41,7 @@ function LaunchConfirmation({ campaign, onSubmit }: LaunchConfirmationProps) {
|
|||
[t('exclusion_lists')]: DelimitedLists({ lists: campaign.exclusion_lists }),
|
||||
}} />
|
||||
<InfoTable rows={{
|
||||
...variants.length ? { [t('variants')]: variantList } : {},
|
||||
...variants.length > 1 ? { [t('variants')]: variantList } : {},
|
||||
[t('translations')]: locales.map(l => l.label).join(', '),
|
||||
}} />
|
||||
<Button variant="primary" type="submit" onClick={onSubmit}>{t(campaign.send_at ? 'reschedule' : 'launch')}</Button>
|
||||
|
|
|
@ -6,7 +6,7 @@ import ButtonGroup from '../../../ui/ButtonGroup'
|
|||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import LocaleListModal from './LocaleListModal'
|
||||
import { useNavigate } from 'react-router'
|
||||
import TemplateCreateModal from '../TemplateCreateModal'
|
||||
import TemplateCreateModal from '../template/TemplateCreateModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface LocaleSelectorParams {
|
||||
|
|
59
apps/ui/src/views/campaign/template/EmailTemplate.tsx
Normal file
59
apps/ui/src/views/campaign/template/EmailTemplate.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { EmailTemplateData, TemplateUpdateParams } from '../../../types'
|
||||
import { useContext } from 'react'
|
||||
import { TemplateContext } from '../../../contexts'
|
||||
import { InfoTable, Tag } from '../../../ui'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import TextInput from '../../../ui/form/TextInput'
|
||||
|
||||
export const EmailTable = ({ data }: { data: EmailTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentTemplate, variants } = useContext(TemplateContext)
|
||||
const validate = (field: string, value: string | undefined, required = true) => {
|
||||
if (!value && required) return <Tag variant="warn">{t('missing')}</Tag>
|
||||
if (['cc', 'bcc', 'reply_to', 'from_email'].includes(field) && value && !value.includes('@')) {
|
||||
return <Tag variant="warn">{t('invalid_email')}: "{value}"</Tag>
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
return <>
|
||||
<InfoTable rows={{
|
||||
...variants.length ? { [t('variant')]: currentTemplate?.name } : {},
|
||||
[t('from_email')]: validate('from_email', data.from?.address),
|
||||
[t('from_name')]: validate('from_name', data.from?.name),
|
||||
[t('reply_to')]: validate('reply_to', data.reply_to, false),
|
||||
[t('cc')]: validate('cc', data.cc, false),
|
||||
[t('bcc')]: validate('bcc', data.bcc, false),
|
||||
[t('subject')]: validate('subject', data.subject),
|
||||
[t('preheader')]: data.preheader,
|
||||
}} />
|
||||
</>
|
||||
}
|
||||
|
||||
export const EmailForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<TextInput.Field form={form} name="data.from.name" label={t('from_name')} required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.from.address"
|
||||
label={t('from_email')}
|
||||
type="email"
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.subject"
|
||||
label={t('subject')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.preheader"
|
||||
label={t('preheader')}
|
||||
textarea />
|
||||
<TextInput.Field form={form} name="data.reply_to" label={t('reply_to')} />
|
||||
<TextInput.Field form={form} name="data.cc" label={t('cc')} />
|
||||
<TextInput.Field form={form} name="data.bcc" label={t('bcc')} />
|
||||
</>
|
||||
}
|
56
apps/ui/src/views/campaign/template/InAppTemplate.tsx
Normal file
56
apps/ui/src/views/campaign/template/InAppTemplate.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { InAppTemplateData, TemplateUpdateParams } from '../../../types'
|
||||
import { InfoTable, Tag } from '../../../ui'
|
||||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import TextInput from '../../../ui/form/TextInput'
|
||||
import JsonField from '../../../ui/form/JsonField'
|
||||
import SwitchField from '../../../ui/form/SwitchField'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const InAppTable = ({ data }: { data: InAppTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
return <InfoTable rows={{
|
||||
[t('type')]: data.type ? t(data.type) : <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('title')]: data.title ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('body')]: data.body ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('read_on_view')]: data.read_on_view,
|
||||
[t('raw_json')]: JSON.stringify(data.custom),
|
||||
}} />
|
||||
}
|
||||
|
||||
export const InAppForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<SingleSelect.Field
|
||||
form={form}
|
||||
name="data.type"
|
||||
label="Method"
|
||||
options={[
|
||||
// { key: 'alert', label: t('alert') },
|
||||
{ key: 'html', label: t('html') },
|
||||
]}
|
||||
toValue={v => v.key}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.title"
|
||||
label="Title"
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.body"
|
||||
label="Body"
|
||||
textarea
|
||||
required />
|
||||
<SwitchField
|
||||
form={form}
|
||||
name="data.read_on_view"
|
||||
label="Read On View"
|
||||
subtitle="Should this in app message be marked as read when its viewed or only when dismissed?" />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.custom"
|
||||
label="Custom"
|
||||
textarea />
|
||||
</>
|
||||
}
|
42
apps/ui/src/views/campaign/template/PushTemplate.tsx
Normal file
42
apps/ui/src/views/campaign/template/PushTemplate.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { PushTemplateData, TemplateUpdateParams } from '../../../types'
|
||||
import { InfoTable, Tag } from '../../../ui'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import TextInput from '../../../ui/form/TextInput'
|
||||
import JsonField from '../../../ui/form/JsonField'
|
||||
|
||||
export const PushTable = ({ data }: { data: PushTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
return <InfoTable rows={{
|
||||
[t('title')]: data.title ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('body')]: data.body ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('deeplink')]: data.url,
|
||||
[t('raw_json')]: JSON.stringify(data.custom),
|
||||
}} />
|
||||
}
|
||||
|
||||
export const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.title"
|
||||
label={t('title')}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.body"
|
||||
label={t('body')}
|
||||
textarea
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.url"
|
||||
label={t('deeplink')} />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.custom"
|
||||
label={t('raw_json')}
|
||||
textarea />
|
||||
</>
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Dispatch, ReactNode, SetStateAction, useEffect, useState } from 'react'
|
||||
import { TemplateContext } from '../../contexts'
|
||||
import { Campaign, LocaleOption, Template } from '../../types'
|
||||
import { languageName } from '../../utils'
|
||||
import { TemplateContext } from '../../../contexts'
|
||||
import { Campaign, LocaleOption, Template } from '../../../types'
|
||||
import { languageName } from '../../../utils'
|
||||
import { useSearchParams } from 'react-router'
|
||||
|
||||
export const localeOption = (locale: string): LocaleOption => {
|
|
@ -1,14 +1,14 @@
|
|||
import { Campaign, LocaleOption } from '../../types'
|
||||
import FormWrapper from '../../ui/form/FormWrapper'
|
||||
import Modal from '../../ui/Modal'
|
||||
import RadioInput from '../../ui/form/RadioInput'
|
||||
import { Campaign, LocaleOption } from '../../../types'
|
||||
import FormWrapper from '../../../ui/form/FormWrapper'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import RadioInput from '../../../ui/form/RadioInput'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import api from '../../api'
|
||||
import { AdminContext, ProjectContext } from '../../contexts'
|
||||
import { SingleSelect } from '../../ui/form/SingleSelect'
|
||||
import { LinkButton } from '../../ui'
|
||||
import api from '../../../api'
|
||||
import { AdminContext, ProjectContext } from '../../../contexts'
|
||||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import { LinkButton } from '../../../ui'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { checkOrganizationRole } from '../../utils'
|
||||
import { checkOrganizationRole } from '../../../utils'
|
||||
import { localeOption } from './TemplateContextProvider'
|
||||
|
||||
interface CreateTemplateParams {
|
91
apps/ui/src/views/campaign/template/TemplateDetail.tsx
Normal file
91
apps/ui/src/views/campaign/template/TemplateDetail.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { useContext, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import api from '../../../api'
|
||||
import { CampaignContext, ProjectContext } from '../../../contexts'
|
||||
import { Template, TemplateUpdateParams } from '../../../types'
|
||||
import Button, { LinkButton } from '../../../ui/Button'
|
||||
import { Column, Columns } from '../../../ui/Columns'
|
||||
import Heading from '../../../ui/Heading'
|
||||
import Modal from '../../../ui/Modal'
|
||||
import Preview from '../../../ui/Preview'
|
||||
import FormWrapper from '../../../ui/form/FormWrapper'
|
||||
import { EmailForm, EmailTable } from './EmailTemplate'
|
||||
import { InAppForm, InAppTable } from './InAppTemplate'
|
||||
import { PushForm, PushTable } from './PushTemplate'
|
||||
import { TextForm, TextTable } from './TextTemplate'
|
||||
import { WebhookForm, WebhookTable } from './WebhookTemplate'
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: Template
|
||||
}
|
||||
|
||||
export default function TemplateDetail({ template }: TemplateDetailProps) {
|
||||
|
||||
const { t } = useTranslation()
|
||||
const [{ id, type, data }, setTemplate] = useState(template)
|
||||
const [campaign, setCampaign] = useContext(CampaignContext)
|
||||
const [project] = useContext(ProjectContext)
|
||||
const [isEditOpen, setIsEditOpen] = useState(false)
|
||||
const showCodeEditor = type === 'email' || type === 'in_app'
|
||||
|
||||
async function handleTemplateSave(params: TemplateUpdateParams) {
|
||||
const value = await api.templates.update(project.id, id, params)
|
||||
setTemplate(value)
|
||||
|
||||
const newCampaign = { ...campaign }
|
||||
newCampaign.templates = campaign.templates.map(obj => obj.id === id ? value : obj)
|
||||
setCampaign(newCampaign)
|
||||
setIsEditOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Columns>
|
||||
<Column>
|
||||
<Heading title={t('details')} size="h4" actions={
|
||||
campaign.state !== 'finished' && <Button size="small" variant="secondary" onClick={() => { setIsEditOpen(true) }}>{t('edit_details')}</Button>
|
||||
} />
|
||||
{
|
||||
{
|
||||
email: <EmailTable data={data} />,
|
||||
text: <TextTable data={data} />,
|
||||
push: <PushTable data={data} />,
|
||||
webhook: <WebhookTable data={data} />,
|
||||
in_app: <InAppTable data={data} />,
|
||||
}[type]
|
||||
}
|
||||
</Column>
|
||||
|
||||
<Column fullscreen={true}>
|
||||
<Heading title={t('design')} size="h4" actions={
|
||||
showCodeEditor && campaign.state !== 'finished' && <LinkButton size="small" variant="secondary" to={`../editor?template=${template.id}`}>{t('edit_design')}</LinkButton>
|
||||
} />
|
||||
<Preview template={{ type, data }} />
|
||||
</Column>
|
||||
</Columns>
|
||||
|
||||
<Modal title={t('edit_template_details')}
|
||||
open={isEditOpen}
|
||||
onClose={() => setIsEditOpen(false)}
|
||||
>
|
||||
<FormWrapper<TemplateUpdateParams>
|
||||
onSubmit={handleTemplateSave}
|
||||
defaultValues={{ data }}
|
||||
submitLabel="Save"
|
||||
>
|
||||
{form => <>
|
||||
{
|
||||
{
|
||||
email: <EmailForm form={form} />,
|
||||
text: <TextForm form={form} />,
|
||||
push: <PushForm form={form} />,
|
||||
webhook: <WebhookForm form={form} />,
|
||||
in_app: <InAppForm form={form} />,
|
||||
}[type]
|
||||
}
|
||||
</>}
|
||||
</FormWrapper>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
44
apps/ui/src/views/campaign/template/TextTemplate.tsx
Normal file
44
apps/ui/src/views/campaign/template/TextTemplate.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { TemplateUpdateParams, TextTemplateData } from '../../../types'
|
||||
import { useContext } from 'react'
|
||||
import { ProjectContext } from '../../../contexts'
|
||||
import { Alert, Heading, InfoTable, Tag } from '../../../ui'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import TextInput from '../../../ui/form/TextInput'
|
||||
|
||||
export const TextTable = ({ data: { text } }: { data: TextTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
const [project] = useContext(ProjectContext)
|
||||
const segmentLength = 160
|
||||
const optOutLength = project.text_opt_out_message?.length ?? 0
|
||||
const baseLength = (text?.length ?? 0)
|
||||
const totalLength = baseLength + optOutLength
|
||||
const isHandlebars = text?.includes('{{') ?? false
|
||||
|
||||
const lengthStr = (length: number) => {
|
||||
const segments = Math.ceil(length / segmentLength)
|
||||
return `${isHandlebars ? '~' : ''}${length}/${segmentLength} characters, ${segments} segment${segments > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return <>
|
||||
<InfoTable rows={{
|
||||
Text: text ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
}} />
|
||||
<Heading title="Send Details" size="h4" />
|
||||
{baseLength > segmentLength && <Alert variant="plain" title="Note" body={`Carriers calculate your send rate as segments per second not messages per second. This campaign will take approximately ${Math.ceil(baseLength / segmentLength)}x longer to send due to its length.`} />}
|
||||
<InfoTable rows={{
|
||||
'Existing User Length': lengthStr(baseLength),
|
||||
'New User Length': lengthStr(totalLength),
|
||||
}} />
|
||||
</>
|
||||
}
|
||||
|
||||
export const TextForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <TextInput.Field
|
||||
form={form}
|
||||
name="data.text"
|
||||
label={t('message')}
|
||||
textarea
|
||||
required />
|
||||
}
|
50
apps/ui/src/views/campaign/template/WebhookTemplate.tsx
Normal file
50
apps/ui/src/views/campaign/template/WebhookTemplate.tsx
Normal file
|
@ -0,0 +1,50 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
import { TemplateUpdateParams, WebhookTemplateData } from '../../../types'
|
||||
import { InfoTable, Tag } from '../../../ui'
|
||||
import { UseFormReturn } from 'react-hook-form'
|
||||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import JsonField from '../../../ui/form/JsonField'
|
||||
import TextInput from '../../../ui/form/TextInput'
|
||||
|
||||
export const WebhookTable = ({ data }: { data: WebhookTemplateData }) => {
|
||||
const { t } = useTranslation()
|
||||
return <InfoTable rows={{
|
||||
[t('method')]: data.method ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('endpoint')]: data.endpoint ?? <Tag variant="warn">{t('missing')}</Tag>,
|
||||
[t('headers')]: JSON.stringify(data.headers),
|
||||
[t('body')]: JSON.stringify(data.body),
|
||||
[t('cache_key')]: data.cache_key,
|
||||
}} />
|
||||
}
|
||||
|
||||
export const WebhookForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> }) => {
|
||||
const { t } = useTranslation()
|
||||
return <>
|
||||
<SingleSelect.Field
|
||||
form={form}
|
||||
name="data.method"
|
||||
label={t('method')}
|
||||
options={['DELETE', 'GET', 'PATCH', 'POST', 'PUT']}
|
||||
required />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.endpoint"
|
||||
label={t('endpoint')}
|
||||
required />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.headers"
|
||||
label={t('headers')}
|
||||
textarea />
|
||||
<JsonField
|
||||
form={form}
|
||||
name="data.body"
|
||||
label={t('body')}
|
||||
textarea />
|
||||
<TextInput.Field
|
||||
form={form}
|
||||
name="data.cache_key"
|
||||
label={t('cache_key')}
|
||||
subtitle={t('cache_key_subtitle')} />
|
||||
</>
|
||||
}
|
|
@ -10,7 +10,7 @@ import { ChannelIcon } from '../../campaign/ChannelTag'
|
|||
import Preview from '../../../ui/Preview'
|
||||
import { SingleSelect } from '../../../ui/form/SingleSelect'
|
||||
import { Heading, LinkButton } from '../../../ui'
|
||||
import { TemplateContextProvider } from '../../campaign/TemplateContextProvider'
|
||||
import { TemplateContextProvider } from '../../campaign/template/TemplateContextProvider'
|
||||
import { TemplateContext } from '../../../contexts'
|
||||
|
||||
interface ActionConfig {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue