feat: final ui modifications for in-app messages

This commit is contained in:
Chris Anderson 2025-08-27 14:43:35 -05:00
parent e46367e1a1
commit ff04913d51
29 changed files with 512 additions and 405 deletions

View file

@ -12,6 +12,7 @@ type BannerNotification = BaseNotification & { type: 'banner' }
interface StyledNotification extends BaseNotification {
html: string
read_on_show?: boolean
}
interface AlertNotification extends StyledNotification {

View file

@ -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)
}
}

View file

@ -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))
}

View file

@ -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)
}

View file

@ -1,7 +1,6 @@
import { BaseNotification } from '../../notifications/Notification'
export interface BasePush extends BaseNotification {
topic: string
silent: boolean
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -123,7 +123,6 @@ const templateDataWebhookParams = {
const templateDataInAppParams = {
type: 'object',
required: ['html'],
properties: {
html: { type: 'string' },
custom: {

View file

@ -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.",

View file

@ -473,6 +473,7 @@ export type InAppTemplateData = {
body: string
custom: Record<string, string | number>
type: NotificationType
read_on_view?: boolean
} & (
| {
type: 'alert'

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>

View file

@ -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() {

View file

@ -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)

View file

@ -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 />
}

View file

@ -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')}: &quot;{value}&quot;</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>
</>
)
}

View file

@ -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'))

View file

@ -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>

View file

@ -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 {

View 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')}: &quot;{value}&quot;</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')} />
</>
}

View 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 />
</>
}

View 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 />
</>
}

View file

@ -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 => {

View file

@ -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 {

View 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>
</>
)
}

View 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 />
}

View 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')} />
</>
}

View file

@ -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 {