feat: campaign experimentation (#695)

This commit is contained in:
Chris Anderson 2025-08-26 15:55:36 -05:00 committed by GitHub
parent 8e5a8b43b8
commit c65bb196ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 932 additions and 467 deletions

View file

@ -1,6 +1,8 @@
import jsonpath from 'jsonpath'
import { PageParams } from '../core/searchParams'
import { GetProjectRulePath, ProjectRulePath, RulePathVisibility } from '../rules/ProjectRulePath'
import { KeyedSet } from '../utilities'
import { userPathForQuery } from '../rules/RuleHelpers'
type PagedRulePathParams = {
search: PageParams,
@ -64,3 +66,67 @@ export const getRulePaths = async (
eventPaths: {},
} as RulePaths)
}
export const filterObjectForRulePaths = async <T extends Record<string, any>>(obj: T, projectId: number, visibilities: RulePathVisibility[] = ['public']) => {
const rulePaths = await ProjectRulePath.all(q => q
.where('project_id', projectId)
.whereNotIn('visibility', visibilities)
.select('path', 'type', 'name', 'data_type', 'visibility'),
)
return removeByJsonPaths(obj, rulePaths.map(rp => rp.path))
}
type PathToken = string | number
type PathTask = {
parentPath: PathToken[]
key: PathToken
}
const removeByJsonPaths = <T extends Record<string, any>>(obj: T, paths: string[]): T => {
const list = paths.map(p => userPathForQuery(p))
const tasks: PathTask[] = []
// Collect concrete matches up front so indices don't shift during deletion
for (const p of list) {
const nodes = jsonpath.nodes(obj as unknown as object, p) as Array<{ path: PathToken[] }>
for (const { path } of nodes) {
if (!path || path.length <= 1) continue
tasks.push({
parentPath: path.slice(0, -1),
key: path[path.length - 1],
})
}
}
// For items under the same parent array, delete higher indices first.
// Otherwise, delete shallower parents first.
const parentKey = (x: PathTask) => x.parentPath.join('|')
tasks.sort((a, b) => {
const aP = parentKey(a)
const bP = parentKey(b)
if (aP === bP && typeof a.key === 'number' && typeof b.key === 'number') {
return b.key - a.key
}
return a.parentPath.length - b.parentPath.length
})
// Perform deletions
for (const t of tasks) {
let parent: any = obj
// Walk from '$' to the parent container
for (let i = 1; i < t.parentPath.length; i++) {
if (parent == null) break
parent = parent[t.parentPath[i] as any]
}
if (parent == null) continue
if (Array.isArray(parent) && typeof t.key === 'number') {
const idx = t.key
if (idx >= 0 && idx < parent.length) parent.splice(idx, 1)
} else {
if (Object.prototype.hasOwnProperty.call(parent, t.key)) delete parent[t.key as any]
}
}
return obj
}

View file

@ -9,9 +9,9 @@ import Project from '../projects/Project'
import { EncodedJob } from '../queue'
import { RenderContext } from '../render'
import Template, { TemplateType } from '../render/Template'
import { templateInUserLocale } from '../render/TemplateService'
import { templatesInUserLocale } from '../render/TemplateService'
import { User } from '../users/User'
import { randomInt } from '../utilities'
import { random, randomInt } from '../utilities'
import { MessageTrigger } from './MessageTrigger'
import JourneyProcessJob from '../journey/JourneyProcessJob'
import { createEvent } from '../users/UserEventRepository'
@ -63,7 +63,9 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
)
// Determine what template to send to the user based on the following
const template = templateInUserLocale(templates, project, user)
const template = random(
templatesInUserLocale(templates, project, user),
)
// If campaign or template dont exist, log and abort
if (!template || !campaign) {
@ -82,6 +84,8 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
campaign_name: campaign.name,
campaign_type: campaign.type,
template_id: template.id,
template_name: template.name,
template_locale: template.locale,
channel: campaign.channel,
subscription_id: campaign.subscription_id,
reference_type,

View file

@ -10,6 +10,7 @@ import { paramsToEncodedLink } from './LinkService'
export default class Template extends Model {
project_id!: number
campaign_id!: number
name?: string
type!: ChannelType
data!: Record<string, any>
locale!: string
@ -44,7 +45,7 @@ export default class Template extends Model {
}
export type TemplateParams = Omit<Template, ModelParams | 'map' | 'screenshotUrl' | 'validate' | 'requiredErrors'>
export type TemplateUpdateParams = Pick<Template, 'type' | 'data'>
export type TemplateUpdateParams = Pick<Template, 'type' | 'name' | 'data'>
export type TemplateType = EmailTemplate | TextTemplate | PushTemplate | WebhookTemplate
type CompiledEmail = Omit<Email, 'to' | 'headers'> & { preheader?: string }

View file

@ -130,6 +130,7 @@ const templateCreateParams: JSONSchemaType<TemplateParams> = {
type: 'string',
enum: ['email'],
},
name: { type: 'string', nullable: true },
campaign_id: { type: 'integer' },
locale: { type: 'string' },
data: templateDataEmailParams as any,
@ -144,6 +145,7 @@ const templateCreateParams: JSONSchemaType<TemplateParams> = {
type: 'string',
enum: ['text'],
},
name: { type: 'string', nullable: true },
campaign_id: { type: 'integer' },
locale: { type: 'string' },
data: templateDataTextParams as any,
@ -158,6 +160,7 @@ const templateCreateParams: JSONSchemaType<TemplateParams> = {
type: 'string',
enum: ['push'],
},
name: { type: 'string', nullable: true },
campaign_id: { type: 'integer' },
locale: { type: 'string' },
data: templateDataPushParams as any,
@ -172,6 +175,7 @@ const templateCreateParams: JSONSchemaType<TemplateParams> = {
type: 'string',
enum: ['webhook'],
},
name: { type: 'string', nullable: true },
campaign_id: { type: 'integer' },
locale: { type: 'string' },
data: templateDataWebhookParams as any,
@ -207,6 +211,7 @@ const templateUpdateParams: JSONSchemaType<TemplateUpdateParams> = {
type: 'string',
enum: ['email'],
},
name: { type: 'string', nullable: true },
data: templateDataEmailParams as any,
},
additionalProperties: false,
@ -219,6 +224,7 @@ const templateUpdateParams: JSONSchemaType<TemplateUpdateParams> = {
type: 'string',
enum: ['text'],
},
name: { type: 'string', nullable: true },
data: templateDataTextParams as any,
},
additionalProperties: false,
@ -231,6 +237,7 @@ const templateUpdateParams: JSONSchemaType<TemplateUpdateParams> = {
type: 'string',
enum: ['push'],
},
name: { type: 'string', nullable: true },
data: templateDataPushParams as any,
},
additionalProperties: false,
@ -243,13 +250,15 @@ const templateUpdateParams: JSONSchemaType<TemplateUpdateParams> = {
type: 'string',
enum: ['webhook'],
},
name: { type: 'string', nullable: true },
data: templateDataWebhookParams as any,
},
additionalProperties: false,
}],
}
router.patch('/:templateId', async ctx => {
const payload = validate(templateUpdateParams, ctx.request.body)
const body = { ...ctx.request.body, type: ctx.state.template!.type }
const payload = validate(templateUpdateParams, body)
ctx.body = await updateTemplate(ctx.state.template!.id, payload)
})

View file

@ -40,19 +40,12 @@ export const getTemplate = async (id: number, projectId: number) => {
}
export const createTemplate = async (projectId: number, params: TemplateParams) => {
const hasLocale = await Template.exists(
qb => qb.where('locale', params.locale)
.where('project_id', projectId)
.where('campaign_id', params.campaign_id),
)
if (hasLocale) throw new RequestError('A template with this locale already exists.')
const template = await Template.insertAndFetch({
return await Template.insertAndFetch({
...params,
name: params.name ?? 'Control',
data: params.data ?? {},
project_id: projectId,
})
return template
}
export const updateTemplate = async (templateId: number, params: TemplateUpdateParams) => {
@ -190,3 +183,15 @@ export const templateInUserLocale = (templates: Template[], project?: Project, u
|| templates.find(item => partialMatchLocale(item.locale, project?.locale))
|| templates[0]
}
export const templatesInUserLocale = (templates: Template[], project?: Project, user?: User) => {
let results = templates.filter(item => item.locale === user?.locale)
if (results.length) return results
results = templates.filter(item => partialMatchLocale(item.locale, user?.locale))
if (results.length) return results
results = templates.filter(item => item.locale === project?.locale)
if (results.length) return results
results = templates.filter(item => partialMatchLocale(item.locale, project?.locale))
if (results.length) return results
return templates
}

View file

@ -16,6 +16,14 @@ export const queryValue = <T>(
return jsonpath.query(value, path).map(v => cast(v))
}
export const userPathForQuery = (path: string) => {
const column = path.replace('$.', '')
if (reservedPaths.user.includes(column)) {
return path
}
return '$.data' + path.replace('$', '')
}
const formattedQueryValue = (value: any) => typeof value === 'string' ? `'${value}'` : value
export const queryPath = (rule: RuleTree): string => {

View file

@ -380,7 +380,6 @@ export const subscriptionUpdateSchema: JSONSchemaType<SubscriptionUpdateParams>
}
router.patch('/:subscriptionId', async ctx => {
const payload = validate(subscriptionUpdateSchema, ctx.request.body)
console.log(payload)
ctx.body = await updateSubscription(ctx.state.subscription!.id, payload)
})

View file

@ -15,6 +15,8 @@ import { getUserEvents } from './UserEventRepository'
import { projectRoleMiddleware } from '../projects/ProjectService'
import { pagedEntrancesByUser } from '../journey/JourneyRepository'
import { removeUsers } from './UserImport'
import { filterObjectForRulePaths } from '../projects/ProjectRulePathRepository'
import { RulePathVisibility } from '../rules/ProjectRulePath'
const router = new Router<
ProjectState & { user?: User }
@ -166,7 +168,11 @@ router.param('userId', async (value, ctx, next) => {
})
router.get('/:userId', async ctx => {
ctx.body = ctx.state.user
const visibilities: RulePathVisibility[] = ctx.state.projectRole === 'admin'
? ['public', 'classified']
: ['public']
ctx.body = await filterObjectForRulePaths(ctx.state.user!, ctx.state.project.id, visibilities)
})
router.delete('/:userId', projectRoleMiddleware('editor'), async ctx => {

View file

@ -8,7 +8,10 @@ import { Database } from '../config/database'
export const pluralize = (noun: string, count = 2, suffix = 's') => `${noun}${count !== 1 ? suffix : ''}`
export const random = <T>(array: T[]): T => array[Math.floor(Math.random() * array.length)]
export const random = <T>(array: T[]): T => {
if (array.length === 1) return array[0]
return array[Math.floor(Math.random() * array.length)]
}
export const cleanString = (value: string | undefined): string | undefined => {
if (value === 'NULL' || value == null || value === 'undefined' || value === '') return undefined

View file

@ -43,6 +43,7 @@
"campaign_form_type": "Should a campaign be sent as a blast to a list of users or triggered individually via API.",
"campaign_list_generating": "This list is still generating or has not been published. Sending before it has completed could result in this campaign not sending to all users who will enter the list. Are you sure you want to continue?",
"campaign_name": "Campaign Name",
"campaign_variant_add": "Create Experiment",
"campaigns": "Campaigns",
"campaign": "Campaign",
"cancel": "Cancel",
@ -55,6 +56,7 @@
"completed": "Completed",
"components": "Components",
"confirm_unsaved_changes": "Are you sure you want to leave? You have unsaved changes.",
"continue": "Continue",
"create": "Create",
"create_campaign": "Create Campaign",
"create_journey": "Create Journey",
@ -198,7 +200,8 @@
"launch": "Launch",
"launch_campaign": "Launch Campaign",
"launch_period": "Launch Period",
"launch_subtitle": "Please check to ensure all settings are correct before launching a campaign. A scheduled campaign can be aborted, but one sent immediately cannot.",
"launch_subtitle": "Pick when this blast campaign should send to the selected audience.",
"launch_confirmation_subtitle": "Please check to ensure all settings are correct before launching a campaign. A campaign can be aborted at any time until finished but some messages may already have sent.",
"launched_at": "Launched At",
"light_mode": "Use Light Theme",
"link": "Link",
@ -396,6 +399,14 @@
"users_count": "Users Count",
"users_unsubscribe_all": "Are you sure you want to unsubscribe from all?",
"value": "Value",
"variant": "Variant",
"variant_add": "Add Variant",
"variant_create": "Create Variant",
"variant_remove_warning": "Are you sure you want to delete this variant? The template cannot be recovered.",
"variant_update": "Edit Variant",
"variant_save": "Save Variant",
"variants": "Variants",
"variants_description": "Experiment with different variants of a template to test what works best. Users will be split proportionally across each variant.",
"view_all": "View All",
"visibility": "Visibility",
"visual": "Visual",

View file

@ -32,10 +32,30 @@ export const CampaignContext = createContext<UseStateContext<Campaign>>([
() => {},
])
export const TemplateContext = createContext<UseStateContext<Template>>([
{} as unknown as Template,
() => {},
])
interface TemplateManager {
campaign: Campaign
setCampaign: Dispatch<SetStateAction<Campaign>>
currentTemplate?: Template
templates: Template[]
currentLocale?: LocaleOption
locales: LocaleOption[]
variants: Template[]
variantMap: Record<string, Template[]>
setTemplate: Dispatch<SetStateAction<Template | undefined>>
setLocale: (locale: LocaleOption | string | undefined) => void
}
export const TemplateContext = createContext<TemplateManager>({
campaign: {} as unknown as Campaign,
setCampaign: () => {},
currentTemplate: undefined,
templates: [],
currentLocale: undefined,
locales: [],
variants: [],
variantMap: {},
setTemplate: () => {},
setLocale: () => {},
})
export const ListContext = createContext<UseStateContext<List>>([
{} as unknown as List,

View file

@ -428,7 +428,8 @@ export type CampaignSendState = 'pending' | 'sent' | 'throttled' | 'failed' | 'b
export type CampaignUpdateParams = Partial<Pick<Campaign, 'name' | 'state' | 'list_ids' | 'exclusion_list_ids' | 'subscription_id' | 'tags'>>
export type CampaignCreateParams = Pick<Campaign, 'name' | 'type' | 'list_ids' | 'exclusion_list_ids' | 'channel' | 'subscription_id' | 'provider_id' | 'tags'>
export type CampaignLaunchParams = Pick<Campaign, 'send_at' | 'send_in_user_timezone' | 'state'>
export type CampaignLaunchType = 'now' | 'later'
export type CampaignLaunchParams = Pick<Campaign, 'send_at' | 'send_in_user_timezone' | 'state'> & { launch_type?: CampaignLaunchType }
// export type ListUpdateParams = Pick<List, 'name' | 'rule'>
export type CampaignUser = User & { state: CampaignSendState, send_at: string }
@ -469,6 +470,7 @@ export interface WebhookTemplateData {
export type Template = {
id: number
campaign_id: number
name?: string
type: ChannelType
locale: string
data: any
@ -494,8 +496,9 @@ export type Template = {
}
)
export type TemplateCreateParams = Pick<Template, 'type' | 'data' | 'campaign_id' | 'locale'>
export type TemplateUpdateParams = Pick<Template, 'type' | 'data'>
export type TemplateCreateParams = Pick<Template, 'name' | 'type' | 'data' | 'campaign_id' | 'locale'>
export type TemplateUpdateParams = Pick<Template, 'name' | 'data'>
export type VariantUpdateParams = Pick<Template, 'name'> & { id?: number }
export interface TemplatePreviewParams {
user: Record<string, any>
@ -613,6 +616,7 @@ export interface Metric {
export interface LocaleOption {
key: string
label: string
shortLabel?: string
}
export interface Locale extends LocaleOption {

View file

@ -1,4 +1,4 @@
import { forwardRef, PropsWithChildren, Ref } from 'react'
import { forwardRef, MouseEvent, PropsWithChildren, Ref } from 'react'
import clsx from 'clsx'
import { Link, To } from 'react-router'
import './Button.css'
@ -15,7 +15,7 @@ type BaseButtonProps = PropsWithChildren<{
}> & JSX.IntrinsicElements['button']
type ButtonProps = {
onClick?: () => void
onClick?: (event: MouseEvent<HTMLButtonElement>) => void
type?: 'button' | 'submit'
} & BaseButtonProps

View file

@ -46,9 +46,9 @@ export default function Modal({
leaveFrom="transition-leave-from"
leaveTo="transition-leave-to"
>
<div className="modal-overlay" />
<Dialog.Overlay className="modal-overlay" style={{ zIndex }} />
</Transition.Child>
<div className="modal-wrapper">
<div className="modal-wrapper" style={{ zIndex: zIndex + 1 }}>
<Transition.Child
as={Fragment}
enter="transition-enter"

View file

@ -1,31 +1,31 @@
import { useContext, useState } from 'react'
import { CampaignContext, LocaleContext } from '../../contexts'
import { useTranslation } from 'react-i18next'
import { TemplateContext } from '../../contexts'
import Alert from '../../ui/Alert'
import Button from '../../ui/Button'
import Heading from '../../ui/Heading'
import LocaleSelector from './LocaleSelector'
import LocaleSelector from './locale/LocaleSelector'
import TemplateDetail from './TemplateDetail'
import { useTranslation } from 'react-i18next'
import VariantSelector from './variants/VariantSelector'
export default function CampaignDesign() {
const { t } = useTranslation()
const campaignState = useContext(CampaignContext)
const { templates } = campaignState[0]
const [{ currentLocale }] = useContext(LocaleContext)
const { currentTemplate, templates } = useContext(TemplateContext)
const showAddState = useState(false)
return (
<>
<Heading title={t('design')} size="h3" actions={
<LocaleSelector
campaignState={campaignState}
showAddState={showAddState} />
<>
<VariantSelector />
<LocaleSelector showAddState={showAddState} />
</>
} />
{templates.filter(template => template.locale === currentLocale?.key)
{templates.filter(template => template.id === currentTemplate?.id)
.map(template => (
<TemplateDetail template={template} key={template.id} />
))}
{!currentLocale
{!currentTemplate
&& <Alert
variant="plain"
title={t('add_template')}

View file

@ -2,67 +2,23 @@ import Button from '../../ui/Button'
import PageContent from '../../ui/PageContent'
import { Outlet, useNavigate } from 'react-router'
import { NavigationTabs } from '../../ui/Tabs'
import { useContext, useEffect, useState } from 'react'
import { CampaignContext, LocaleContext, LocaleSelection, ProjectContext } from '../../contexts'
import { checkProjectRole, languageName } from '../../utils'
import { Campaign, LocaleOption, Template } from '../../types'
import { useContext, useState } from 'react'
import { CampaignContext, ProjectContext } from '../../contexts'
import { checkProjectRole } from '../../utils'
import api from '../../api'
import { CampaignTag } from './Campaigns'
import LaunchCampaign from './LaunchCampaign'
import LaunchCampaign from './launch/LaunchCampaign'
import { ArchiveIcon, DuplicateIcon, ForbiddenIcon, RestartIcon, SendIcon } from '../../ui/icons'
import { useTranslation } from 'react-i18next'
import { Menu, MenuItem } from '../../ui'
export interface LocaleParams {
locale: string
data: {
editor: string
}
}
export const localeOption = (locale: string): LocaleOption => {
const language = languageName(locale)
return {
key: locale,
label: language ? `${language} (${locale})` : locale,
}
}
export const locales = (templates: Template[]) => templates?.map(item => localeOption(item.locale))
export const localeState = (templates: Template[]) => {
const allLocales = locales(templates)
const url: URL = new URL(window.location.href)
const searchParams: URLSearchParams = url.searchParams
const queryLocale = searchParams.get('locale')
return {
currentLocale: allLocales.find(item => item.key === queryLocale) ?? allLocales[0],
allLocales: locales(templates ?? []),
}
}
export const createLocale = async ({ locale, data }: LocaleParams, campaign: Campaign): Promise<Template> => {
const baseLocale = 'en'
const template = campaign.templates.find(template => template.locale === baseLocale) ?? campaign.templates[0]
return await api.templates.create(campaign.project_id, {
campaign_id: campaign.id,
type: campaign.channel,
locale,
data: template?.data || data ? { ...template?.data, ...data } : undefined,
})
}
import { TemplateContextProvider } from './TemplateContextProvider'
export default function CampaignDetail() {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const navigate = useNavigate()
const [campaign, setCampaign] = useContext(CampaignContext)
const { name, templates, state, send_at, progress } = campaign
const [locale, setLocale] = useState<LocaleSelection>(localeState(templates ?? []))
useEffect(() => {
setLocale(localeState(templates ?? []))
}, [campaign.id])
const { name, state, send_at, progress } = campaign
const [isLaunchOpen, setIsLaunchOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
@ -187,11 +143,10 @@ export default function CampaignDetail() {
}
fullscreen={true}>
<NavigationTabs tabs={tabs} />
<LocaleContext.Provider value={[locale, setLocale]}>
<TemplateContextProvider campaign={campaign} setCampaign={setCampaign}>
<Outlet />
</LocaleContext.Provider>
<LaunchCampaign open={isLaunchOpen} onClose={setIsLaunchOpen} />
<LaunchCampaign open={isLaunchOpen} onClose={setIsLaunchOpen} />
</TemplateContextProvider>
</PageContent>
)
}

View file

@ -1,7 +1,5 @@
import { ReactNode, useContext, useState } from 'react'
import { Link } from 'react-router'
import { useContext, useState } from 'react'
import { CampaignContext, ProjectContext } from '../../contexts'
import { Journey, List } from '../../types'
import Button from '../../ui/Button'
import Heading from '../../ui/Heading'
import { InfoTable } from '../../ui/InfoTable'
@ -14,7 +12,7 @@ import ChannelTag from './ChannelTag'
import CodeExample from '../../ui/CodeExample'
import { env } from '../../config/env'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../ui'
import { DelimitedJourneys, DelimitedLists } from './ui/DelimitedItems'
export default function CampaignOverview() {
const [project] = useContext(ProjectContext)
@ -23,47 +21,6 @@ export default function CampaignOverview() {
const [campaign, setCampaign] = useContext(CampaignContext)
const [isEditOpen, setIsEditOpen] = useState(false)
interface DelimitedItemParams {
items?: any[]
delimiter?: ReactNode
mapper: (item: any) => { id: string | number, title: string, url: string }
}
const DelimitedItems = ({ items, delimiter = ' ', mapper }: DelimitedItemParams) => {
if (!items || items?.length === 0) return <>&#8211;</>
return <div className="tag-list">
{items?.map<ReactNode>(
item => {
const { id, title, url } = mapper(item)
return (
<Tag variant="plain" key={id}><Link to={url}>{title}</Link></Tag>
)
},
)?.reduce((prev, curr) => prev ? [prev, delimiter, curr] : curr, '')}
</div>
}
const DelimitedJourneys = ({ journeys }: { journeys?: Journey[] }) => {
return DelimitedItems({
items: journeys,
mapper: (journey) => ({
id: journey.id,
title: journey.name,
url: `/projects/${project.id}/journeys/${journey.id}`,
}),
})
}
const DelimitedLists = ({ lists }: { lists?: List[] }) => {
return DelimitedItems({
items: lists,
mapper: (list) => ({
id: list.id,
title: list.name,
url: `/projects/${project.id}/lists/${list.id}`,
}),
})
}
const canEdit = campaign.type === 'trigger' || campaign.state === 'draft' || campaign.state === 'aborted'
const extra = campaign.channel === 'text'

View file

@ -1,12 +1,12 @@
import { useContext, useMemo, useState, useEffect } from 'react'
import { CampaignContext, LocaleContext, ProjectContext } from '../../contexts'
import { ProjectContext, TemplateContext } from '../../contexts'
import './CampaignPreview.css'
import api from '../../api'
import Preview from '../../ui/Preview'
import { toast } from 'react-hot-toast/headless'
import { debounce } from '../../utils'
import Heading from '../../ui/Heading'
import LocaleSelector from './LocaleSelector'
import LocaleSelector from './locale/LocaleSelector'
import Alert from '../../ui/Alert'
import Button from '../../ui/Button'
import { Column, Columns } from '../../ui/Columns'
@ -18,6 +18,7 @@ import SourceEditor from '../../ui/SourceEditor'
import { useTranslation } from 'react-i18next'
import { flattenUser } from '../../ui/utils'
import { UserLookup } from '../users/UserLookup'
import VariantSelector from './variants/VariantSelector'
interface SendProofProps extends Omit<ModalProps, 'title'> {
type: ChannelType
@ -46,21 +47,20 @@ export default function CampaignPreview() {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const campaignState = useContext(CampaignContext)
const [{ currentLocale }] = useContext(LocaleContext)
const { currentTemplate } = useContext(TemplateContext)
const showAddState = useState(false)
const [isUserLookupOpen, setIsUserLookupOpen] = useState(false)
const [templatePreviewError, setTemplatePreviewError] = useState<string | undefined>(undefined)
const [isSendProofOpen, setIsSendProofOpen] = useState(false)
const template = campaignState[0].templates.find(template => template.locale === currentLocale?.key)
const [proofResponse, setProofResponse] = useState<any>(undefined)
if (!template) {
if (!currentTemplate) {
return (<>
<Heading title={t('preview')} size="h3" actions={
<LocaleSelector
campaignState={campaignState}
showAddState={showAddState} />
<>
<VariantSelector />
<LocaleSelector showAddState={showAddState} />
</>
} />
<Alert
variant="plain"
@ -71,13 +71,13 @@ export default function CampaignPreview() {
</>)
}
const [data, setData] = useState(template.data)
const [data, setData] = useState(currentTemplate.data)
const [value, setValue] = useState<string | undefined>('{\n "user": {},\n "event": {}\n}')
useEffect(() => { handleEditorChange(value) }, [value, template])
useEffect(() => { handleEditorChange(value) }, [value, currentTemplate])
const handleEditorChange = useMemo(() => debounce(async (value?: string) => {
try {
const { data } = await api.templates.preview(project.id, template.id, JSON.parse(value ?? '{}'))
const { data } = await api.templates.preview(project.id, currentTemplate.id, JSON.parse(value ?? '{}'))
setTemplatePreviewError(undefined)
setData(data)
} catch (error: any) {
@ -87,11 +87,11 @@ export default function CampaignPreview() {
}
setTemplatePreviewError(error.message)
}
}), [template])
}), [currentTemplate])
const handleSendProof = async (recipient: string) => {
try {
const response = await api.templates.proof(project.id, template.id, {
const response = await api.templates.proof(project.id, currentTemplate.id, {
variables: JSON.parse(value ?? '{}'),
recipient,
})
@ -105,7 +105,7 @@ export default function CampaignPreview() {
return
}
setIsSendProofOpen(false)
template.type === 'webhook'
currentTemplate.type === 'webhook'
? toast.success('Webhook test has been successfully sent!')
: toast.success('Template proof has been successfully sent!')
}
@ -113,9 +113,10 @@ export default function CampaignPreview() {
return (
<>
<Heading title="Preview" size="h3" actions={
<LocaleSelector
campaignState={campaignState}
showAddState={showAddState} />
<>
<VariantSelector />
<LocaleSelector showAddState={showAddState} />
</>
} />
<Columns>
<Column fullscreen={true}>
@ -136,7 +137,7 @@ export default function CampaignPreview() {
</Column>
<Column fullscreen={true}>
<Heading title="Preview" size="h4" actions={
template.type === 'webhook'
currentTemplate.type === 'webhook'
? <Button
size="small"
variant="secondary"
@ -151,7 +152,7 @@ export default function CampaignPreview() {
title={t('template_error')}>
{t('template_handlebars_error')}{templatePreviewError}
</Alert>}
<Preview template={{ type: template.type, data }} response={proofResponse} />
<Preview template={{ type: currentTemplate.type, data }} response={proofResponse} />
</Column>
</Columns>
@ -168,7 +169,7 @@ export default function CampaignPreview() {
open={isSendProofOpen}
onClose={setIsSendProofOpen}
onSubmit={handleSendProof}
type={template.type} />
type={currentTemplate.type} />
</>
)
}

View file

@ -1,146 +0,0 @@
import { formatISO, isPast } from 'date-fns'
import { useContext, useState } from 'react'
import api from '../../api'
import { CampaignContext, ProjectContext } from '../../contexts'
import { CampaignLaunchParams } from '../../types'
import RadioInput from '../../ui/form/RadioInput'
import SwitchField from '../../ui/form/SwitchField'
import TextInput from '../../ui/form/TextInput'
import FormWrapper from '../../ui/form/FormWrapper'
import Modal from '../../ui/Modal'
import Alert from '../../ui/Alert'
import { zonedTimeToUtc } from 'date-fns-tz'
import { Column, Columns } from '../../ui/Columns'
import { useController } from 'react-hook-form'
import { SelectionProps } from '../../ui/form/Field'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
interface DateTimeFieldProps extends SelectionProps<CampaignLaunchParams> {
label: string
required?: boolean
}
function DateTimeField({ name, control, required }: DateTimeFieldProps) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const { field: { onChange } } = useController({
control,
name,
rules: {
required,
},
})
const handleOnChange = () => {
if (!date || !time) return
const localDate = new Date(`${date}T${time}`)
const utcDate = zonedTimeToUtc(localDate, project.timezone)
onChange(utcDate.toISOString())
}
const handleSetDate = (value: string) => {
setDate(value)
handleOnChange()
}
const handleSetTime = (value: string) => {
setTime(value)
handleOnChange()
}
return <div className="date-time">
<Columns>
<Column>
<TextInput<string>
type="date"
name="date"
label={t('send_at_date')}
onChange={handleSetDate}
onBlur={handleOnChange}
value={date}
required={required} />
</Column>
<Column>
<TextInput<string>
type="time"
name="time"
label={t('send_at_time')}
onChange={handleSetTime}
onBlur={handleOnChange}
value={time}
required={required} />
</Column>
</Columns>
<span className="label-subtitle">
{t('send_at_timezone_notice')}
</span>
</div>
}
interface LaunchCampaignParams {
open: boolean
onClose: (open: boolean) => void
}
export default function LaunchCampaign({ open, onClose }: LaunchCampaignParams) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const navigate = useNavigate()
const [campaign, setCampaign] = useContext(CampaignContext)
const [launchType, setLaunchType] = useState('now')
const [error, setError] = useState<string | undefined>()
async function handleLaunchCampaign(params: CampaignLaunchParams) {
const sendAt = params.send_at ? Date.parse(params.send_at) : new Date()
if (launchType === 'later'
&& isPast(sendAt)
&& !confirm('Are you sure you want to launch a campaign in the past? Messages will go out immediately.')) {
return
}
params.send_at = formatISO(sendAt)
params.state = 'scheduled'
try {
const value = await api.campaigns.update(project.id, campaign.id, params)
setCampaign(value)
onClose(false)
await navigate('delivery')
} catch (error: any) {
if (error?.response?.data) {
setError(error?.response?.data?.error)
}
}
}
return <Modal title={t('launch_campaign')} open={open} onClose={onClose}>
{error && <Alert variant="error" title="Error">{error}</Alert>}
<p>{t('launch_subtitle')}</p>
<FormWrapper<CampaignLaunchParams>
submitLabel={t(campaign.send_at ? 'reschedule' : 'launch')}
onSubmit={handleLaunchCampaign}>
{form => <>
<RadioInput
label={t('launch_period')}
options={[{ key: 'now', label: t('now') }, { key: 'later', label: t('schedule') }]}
value={launchType}
onChange={setLaunchType} />
{launchType === 'later' && <>
<DateTimeField
control={form.control}
name="send_at"
label={t('Send At')}
required />
<SwitchField
form={form}
name="send_in_user_timezone"
label={t('send_in_user_timezone')}
subtitle={t('send_in_user_timezone_desc')} />
</>}
</>}
</FormWrapper>
</Modal>
}

View file

@ -0,0 +1,67 @@
import { Dispatch, ReactNode, SetStateAction, useEffect, useState } from 'react'
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 => {
const language = languageName(locale)
return {
key: locale,
label: language ? `${language} (${locale})` : locale,
shortLabel: language ? `${language}` : locale,
}
}
export const locales = (templates: Template[]) => {
const locales = [...new Set(templates.map(item => item.locale))]
return locales.map(locale => localeOption(locale))
}
interface TemplateContextProviderParams {
children: ReactNode
campaign: Campaign
setCampaign: Dispatch<SetStateAction<Campaign>>
}
export const TemplateContextProvider = ({ campaign, setCampaign, children }: TemplateContextProviderParams) => {
const { templates } = campaign
const [template, setTemplate] = useState<Template | undefined>()
const [searchParams] = useSearchParams()
const templateId = searchParams.get('template')
useEffect(() => {
setTemplate(templates.find(t => `${t.id}` === templateId) ?? templates[0])
}, [campaign.id, searchParams])
const templateManager = {
campaign,
setCampaign,
currentTemplate: template,
templates,
currentLocale: template?.locale ? localeOption(template?.locale) : undefined,
locales: locales(templates),
variants: templates.filter(t => t.locale === template?.locale),
variantMap: templates.reduce<Record<string, Template[]>>((map, template) => {
if (!map[template.locale]) {
map[template.locale] = []
}
map[template.locale].push(template)
return map
}, {}),
setTemplate,
setLocale: (locale: LocaleOption | string | undefined) => {
const key = typeof locale === 'string'
? locale
: locale?.key
const newTemplate = templates.find(t => t.locale === key)
if (newTemplate) setTemplate(newTemplate)
},
}
return (
<TemplateContext.Provider value={templateManager}>
{children}
</TemplateContext.Provider>
)
}

View file

@ -1,7 +1,6 @@
import { Campaign, LocaleOption } from '../../types'
import FormWrapper from '../../ui/form/FormWrapper'
import Modal from '../../ui/Modal'
import { LocaleParams, createLocale, localeOption } from './CampaignDetail'
import RadioInput from '../../ui/form/RadioInput'
import { useContext, useEffect, useState } from 'react'
import api from '../../api'
@ -10,6 +9,7 @@ import { SingleSelect } from '../../ui/form/SingleSelect'
import { LinkButton } from '../../ui'
import { useTranslation } from 'react-i18next'
import { checkOrganizationRole } from '../../utils'
import { localeOption } from './TemplateContextProvider'
interface CreateTemplateParams {
open: boolean
@ -18,6 +18,13 @@ interface CreateTemplateParams {
onCreate: (campaign: Campaign, locale: LocaleOption) => void
}
interface LocaleParams {
locale: string
data: {
editor: string
}
}
export default function CreateTemplateModal({ open, setIsOpen, campaign, onCreate }: CreateTemplateParams) {
const { t } = useTranslation()
@ -30,11 +37,18 @@ export default function CreateTemplateModal({ open, setIsOpen, campaign, onCreat
.catch(() => {})
}, [])
async function handleCreateTemplate(params: LocaleParams) {
const template = await createLocale(params, campaign)
async function handleCreateTemplate({ locale, data }: LocaleParams) {
const clonedTemplate = campaign.templates.find(template => template.locale === 'en') ?? campaign.templates[0]
const template = await api.templates.create(campaign.project_id, {
campaign_id: campaign.id,
type: campaign.channel,
locale,
data: clonedTemplate?.data || data ? { ...clonedTemplate?.data, ...data } : undefined,
})
const newCampaign = { ...campaign }
newCampaign.templates.push(template)
onCreate(newCampaign, localeOption(params.locale))
onCreate(newCampaign, localeOption(locale))
setIsOpen(false)
}

View file

@ -1,5 +1,5 @@
import { useContext, useState } from 'react'
import { CampaignContext, ProjectContext } from '../../contexts'
import { CampaignContext, ProjectContext, TemplateContext } from '../../contexts'
import Button, { LinkButton } from '../../ui/Button'
import { Column, Columns } from '../../ui/Columns'
import { UseFormReturn } from 'react-hook-form'
@ -18,6 +18,7 @@ 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('@')) {
@ -28,6 +29,7 @@ const EmailTable = ({ data }: { data: EmailTemplateData }) => {
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),
@ -223,7 +225,7 @@ export default function TemplateDetail({ template }: TemplateDetailProps) {
<Column fullscreen={true}>
<Heading title={t('design')} size="h4" actions={
type === 'email' && campaign.state !== 'finished' && <LinkButton size="small" variant="secondary" to={`../editor?locale=${template.locale}`}>{t('edit_design')}</LinkButton>
type === 'email' && campaign.state !== 'finished' && <LinkButton size="small" variant="secondary" to={`../editor?template=${template.id}`}>{t('edit_design')}</LinkButton>
} />
<Preview template={{ type, data }} />
</Column>
@ -235,7 +237,7 @@ export default function TemplateDetail({ template }: TemplateDetailProps) {
>
<FormWrapper<TemplateUpdateParams>
onSubmit={handleTemplateSave}
defaultValues={{ type, data }}
defaultValues={{ data }}
submitLabel="Save"
>
{form => <>

View file

@ -1,29 +1,30 @@
import { SetStateAction, Suspense, lazy, useContext, useEffect, useState } from 'react'
import { CampaignContext, LocaleContext, LocaleSelection, ProjectContext } from '../../../contexts'
import { CampaignContext, ProjectContext, TemplateContext } from '../../../contexts'
import './EmailEditor.css'
import Button, { LinkButton } from '../../../ui/Button'
import api from '../../../api'
import { Campaign, Resource, Template } from '../../../types'
import { Resource, Template } from '../../../types'
import { useBlocker, useNavigate } from 'react-router'
import { localeState } from '../CampaignDetail'
import Modal from '../../../ui/Modal'
import HtmlEditor from './HtmlEditor'
import LocaleSelector from '../LocaleSelector'
import LocaleSelector from '../locale/LocaleSelector'
import { toast } from 'react-hot-toast/headless'
import { QuestionIcon } from '../../../ui/icons'
import { useTranslation } from 'react-i18next'
import ResourceModal from '../ResourceModal'
import ResourceModal from './ResourceModal'
import { TemplateContextProvider } from '../TemplateContextProvider'
import VariantSelector from '../variants/VariantSelector'
const VisualEditor = lazy(async () => await import('./VisualEditor'))
export default function EmailEditor() {
function EmailEditor() {
const navigate = useNavigate()
const { t } = useTranslation()
const [project] = useContext(ProjectContext)
const [campaign, setCampaign] = useContext(CampaignContext)
const { campaign, setCampaign, currentTemplate } = useContext(TemplateContext)
const { templates } = campaign
const [locale, setLocale] = useState<LocaleSelection>(localeState(templates ?? []))
const [resources, setResources] = useState<Resource[]>([])
const [template, setTemplate] = useState<Template | undefined>(templates[0])
@ -50,10 +51,10 @@ export default function EmailEditor() {
}
}, [blocker.state])
async function handleTemplateSave({ id, type, data }: Template) {
async function handleTemplateSave({ id, data }: Template) {
setIsSaving(true)
try {
const value = await api.templates.update(project.id, id, { type, data })
const value = await api.templates.update(project.id, id, { data })
const newCampaign = { ...campaign }
newCampaign.templates = templates.map(obj => obj.id === id ? value : obj)
@ -70,73 +71,74 @@ export default function EmailEditor() {
setTemplate(change)
}
const campaignChange = (change: SetStateAction<Campaign>) => {
setCampaign(change)
}
return (
<Modal
size="fullscreen"
title={campaign.name}
open
onClose={async () => {
await navigate(`../campaigns/${campaign.id}/design?template=${currentTemplate?.id}`)
}}
actions={
<>
<Button
size="small"
variant="secondary"
onClick={() => setShowConfig(true)}
>Config</Button>
<LinkButton
icon={<QuestionIcon />}
variant="secondary"
size="small"
to="https://docs.parcelvoy.com/how-to/campaigns/templates"
target="_blank" />
<VariantSelector />
<LocaleSelector />
{template && (
<Button
size="small"
isLoading={isSaving}
onClick={async () => await handleTemplateSave(template)}
>{t('template_save')}</Button>
)}
</>
}
>
{currentTemplate && <section className="email-editor">
{currentTemplate?.data.editor === 'visual'
? (
<Suspense key={currentTemplate.id} fallback={null}>
<VisualEditor
template={currentTemplate}
setTemplate={handleTemplateChange}
resources={resources}
/>
</Suspense>
)
: <HtmlEditor
template={currentTemplate}
key={currentTemplate.id}
setTemplate={handleTemplateChange} />
}
</section>}
<ResourceModal
open={showConfig}
onClose={() => setShowConfig(false)}
resources={resources}
setResources={setResources}
/>
</Modal>
)
}
export default function EmailEditorWrapper() {
const [campaign, setCampaign] = useContext(CampaignContext)
return (
<>
<LocaleContext.Provider value={[locale, setLocale]}>
<Modal
size="fullscreen"
title={campaign.name}
open
onClose={async () => {
await navigate(`../campaigns/${campaign.id}/design?locale=${locale.currentLocale?.key}`)
}}
actions={
<>
<Button
size="small"
variant="secondary"
onClick={() => setShowConfig(true)}
>Config</Button>
<LinkButton
icon={<QuestionIcon />}
variant="secondary"
size="small"
to="https://docs.parcelvoy.com/how-to/campaigns/templates"
target="_blank" />
<LocaleSelector campaignState={[campaign, campaignChange]} />
{template && (
<Button
size="small"
isLoading={isSaving}
onClick={async () => await handleTemplateSave(template)}
>{t('template_save')}</Button>
)}
</>
}
>
<section className="email-editor">
{templates.filter(template => template.locale === locale.currentLocale?.key)
.map(template => (
template.data.editor === 'visual'
? (
<Suspense key={template.id} fallback={null}>
<VisualEditor
template={template}
setTemplate={handleTemplateChange}
resources={resources}
/>
</Suspense>
)
: <HtmlEditor
template={template}
key={template.id}
setTemplate={handleTemplateChange} />
))
}
</section>
<ResourceModal
open={showConfig}
onClose={() => setShowConfig(false)}
resources={resources}
setResources={setResources}
/>
</Modal>
</LocaleContext.Provider>
<TemplateContextProvider campaign={campaign} setCampaign={setCampaign}>
<EmailEditor />
</TemplateContextProvider>
</>
)
}

View file

@ -2,7 +2,7 @@ import { useState } from 'react'
import { Template } from '../../../types'
import { editor as Editor } from 'monaco-editor'
import Button from '../../../ui/Button'
import ImageGalleryModal, { ImageUpload } from '../ImageGalleryModal'
import ImageGalleryModal, { ImageUpload } from './ImageGalleryModal'
import Preview from '../../../ui/Preview'
import Tabs from '../../../ui/Tabs'
import { ImageIcon } from '../../../ui/icons'

View file

@ -1,14 +1,14 @@
import Modal, { ModalStateProps } from '../../ui/Modal'
import UploadField from '../../ui/form/UploadField'
import { useSearchTableState } from '../../ui/SearchTable'
import Modal, { ModalStateProps } from '../../../ui/Modal'
import UploadField from '../../../ui/form/UploadField'
import { useSearchTableState } from '../../../ui/SearchTable'
import { useCallback, useContext, useState } from 'react'
import { ProjectContext } from '../../contexts'
import api from '../../api'
import { ProjectContext } from '../../../contexts'
import api from '../../../api'
import './ImageGalleryModal.css'
import { Image } from '../../types'
import { Tabs } from '../../ui'
import TextInput from '../../ui/form/TextInput'
import FormWrapper from '../../ui/form/FormWrapper'
import { Image } from '../../../types'
import { Tabs } from '../../../ui'
import TextInput from '../../../ui/form/TextInput'
import FormWrapper from '../../../ui/form/FormWrapper'
import { useTranslation } from 'react-i18next'
export type ImageUpload = Pick<Image, 'url' | 'alt' | 'name'>

View file

@ -1,12 +1,12 @@
import Modal, { ModalStateProps } from '../../ui/Modal'
import Modal, { ModalStateProps } from '../../../ui/Modal'
import './ImageGalleryModal.css'
import { Font, Resource } from '../../types'
import { Font, Resource } from '../../../types'
import { useTranslation } from 'react-i18next'
import FormWrapper from '../../ui/form/FormWrapper'
import TextInput from '../../ui/form/TextInput'
import api from '../../api'
import FormWrapper from '../../../ui/form/FormWrapper'
import TextInput from '../../../ui/form/TextInput'
import api from '../../../api'
import { useContext } from 'react'
import { ProjectContext } from '../../contexts'
import { ProjectContext } from '../../../contexts'
interface ResourceModalProps extends ModalStateProps {
onInsert?: (resource: Resource) => void

View file

@ -1,10 +1,10 @@
import Modal, { ModalStateProps } from '../../ui/Modal'
import Modal, { ModalStateProps } from '../../../ui/Modal'
import { useContext, useState } from 'react'
import { ProjectContext } from '../../contexts'
import api from '../../api'
import { ProjectContext } from '../../../contexts'
import api from '../../../api'
import './ImageGalleryModal.css'
import { Resource } from '../../types'
import { Button, DataTable, Heading } from '../../ui'
import { Resource } from '../../../types'
import { Button, DataTable, Heading } from '../../../ui'
import { useTranslation } from 'react-i18next'
import ResourceFontModal from './ResourceFontModal'

View file

@ -4,7 +4,7 @@ import grapesJS, { Editor } from 'grapesjs'
import grapesJSMJML from 'grapesjs-mjml'
import { useEffect, useState } from 'react'
import { Font, Resource, Template } from '../../../types'
import ImageGalleryModal, { ImageUpload } from '../ImageGalleryModal'
import ImageGalleryModal, { ImageUpload } from './ImageGalleryModal'
interface GrapesAssetManagerProps {
event: 'open' | 'close'

View file

@ -0,0 +1,74 @@
import { useContext, useState } from 'react'
import { CampaignLaunchParams } from '../../../types'
import { SelectionProps } from '../../../ui/form/Field'
import { useTranslation } from 'react-i18next'
import { useController } from 'react-hook-form'
import { zonedTimeToUtc } from 'date-fns-tz'
import { ProjectContext } from '../../../contexts'
import { Column, Columns } from '../../../ui'
import TextInput from '../../../ui/form/TextInput'
interface DateTimeFieldProps extends SelectionProps<CampaignLaunchParams> {
label: string
required?: boolean
}
export default function DateTimeField({ name, control, required }: DateTimeFieldProps) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const [date, setDate] = useState('')
const [time, setTime] = useState('')
const { field: { onChange } } = useController({
control,
name,
rules: {
required,
},
})
const handleOnChange = () => {
if (!date || !time) return
const localDate = new Date(`${date}T${time}`)
const utcDate = zonedTimeToUtc(localDate, project.timezone)
onChange(utcDate.toISOString())
}
const handleSetDate = (value: string) => {
setDate(value)
handleOnChange()
}
const handleSetTime = (value: string) => {
setTime(value)
handleOnChange()
}
return <div className="date-time">
<Columns>
<Column>
<TextInput<string>
type="date"
name="date"
label={t('send_at_date')}
onChange={handleSetDate}
onBlur={handleOnChange}
value={date}
required={required} />
</Column>
<Column>
<TextInput<string>
type="time"
name="time"
label={t('send_at_time')}
onChange={handleSetTime}
onBlur={handleOnChange}
value={time}
required={required} />
</Column>
</Columns>
<span className="label-subtitle">
{t('send_at_timezone_notice')}
</span>
</div>
}

View file

@ -0,0 +1,148 @@
import { formatISO, isPast } from 'date-fns'
import { useContext, useState } from 'react'
import api from '../../../api'
import { CampaignContext, ProjectContext, TemplateContext } from '../../../contexts'
import { Campaign, CampaignLaunchParams } from '../../../types'
import RadioInput from '../../../ui/form/RadioInput'
import SwitchField from '../../../ui/form/SwitchField'
import FormWrapper from '../../../ui/form/FormWrapper'
import Modal from '../../../ui/Modal'
import Alert from '../../../ui/Alert'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router'
import DateTimeField from './DateTimeField'
import { Button, InfoTable } from '../../../ui'
import { snakeToTitle } from '../../../utils'
import { localeOption } from '../TemplateContextProvider'
import { DelimitedLists } from '../ui/DelimitedItems'
import { UseFormReturn, useWatch } from 'react-hook-form'
interface LaunchCampaignParams {
open: boolean
onClose: (open: boolean) => void
}
interface LaunchConfirmationProps {
campaign: Campaign
onSubmit: () => void
}
function LaunchConfirmation({ campaign, onSubmit }: LaunchConfirmationProps) {
const { t } = useTranslation()
const { variants, variantMap, locales } = useContext(TemplateContext)
const variantList = Object.entries(variantMap).map(([locale, variants]) => `${localeOption(locale).shortLabel} (${variants.length}x)`).join(', ')
return <>
<p>{t('launch_confirmation_subtitle')}</p>
<InfoTable rows={{
[t('name')]: campaign.name,
[t('channel')]: t(campaign.channel),
[t('send_at')]: campaign.send_at ? new Date(campaign.send_at).toLocaleString() : t(snakeToTitle('now')),
[t('send_at')]: campaign.send_at ? new Date(campaign.send_at).toLocaleString() : t(snakeToTitle('now')),
[t('send_lists')]: DelimitedLists({ lists: campaign.lists }),
[t('exclusion_lists')]: DelimitedLists({ lists: campaign.exclusion_lists }),
}} />
<InfoTable rows={{
...variants.length ? { [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>
</>
}
interface LaunchFormProps {
onSubmit: (data: CampaignLaunchParams) => Promise<void>
}
function LaunchForm({ onSubmit }: LaunchFormProps) {
const { t } = useTranslation()
const renderForm = ({ form }: { form: UseFormReturn<CampaignLaunchParams> }) => {
const launchType = useWatch({
control: form.control,
name: 'launch_type',
})
return <>
<RadioInput.Field
form={form}
name="launch_type"
label={t('launch_period')}
options={[{ key: 'now', label: t('now') }, { key: 'later', label: t('schedule') }]}
/>
{launchType === 'later' && <>
<DateTimeField
control={form.control}
name="send_at"
label={t('send_at')}
required />
<SwitchField
form={form}
name="send_in_user_timezone"
label={t('send_in_user_timezone')}
subtitle={t('send_in_user_timezone_desc')} />
</>}
</>
}
return <>
<p>{t('launch_subtitle')}</p>
<FormWrapper<CampaignLaunchParams>
submitLabel={t('continue')}
onSubmit={onSubmit}>
{form => renderForm({ form })}
</FormWrapper>
</>
}
export default function LaunchCampaign({ open, onClose }: LaunchCampaignParams) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const navigate = useNavigate()
const [campaign, setCampaign] = useContext(CampaignContext)
const [error, setError] = useState<string | undefined>()
const [launchParams, setLaunchParams] = useState<CampaignLaunchParams | undefined>()
async function handleLaunchCampaign() {
if (!launchParams) return
const { send_at, send_in_user_timezone, launch_type } = launchParams
const sendAt = send_at ? Date.parse(send_at) : new Date()
if (launch_type === 'later'
&& isPast(sendAt)
&& !confirm('Are you sure you want to launch a campaign in the past? Messages will go out immediately.')) {
return
}
const params: CampaignLaunchParams = {
send_at: formatISO(sendAt),
state: 'scheduled',
send_in_user_timezone,
}
try {
const value = await api.campaigns.update(project.id, campaign.id, params)
setCampaign(value)
onClose(false)
await navigate('delivery')
} catch (error: any) {
if (error?.response?.data) {
setError(error?.response?.data?.error)
}
}
}
const handleCancel = () => {
onClose(false)
setLaunchParams(undefined)
}
return <Modal
title={t('launch_campaign')}
open={open}
onClose={handleCancel}
size={launchParams ? 'regular' : 'small'}
>
{error && <Alert variant="error" title="Error">{error}</Alert>}
{launchParams
? <LaunchConfirmation
campaign={campaign}
onSubmit={async () => await handleLaunchCampaign()}
/>
: <LaunchForm onSubmit={async (params) => setLaunchParams(params)} />}
</Modal>
}

View file

@ -1,25 +1,25 @@
import { Campaign } from '../../types'
import Modal from '../../ui/Modal'
import { DataTable } from '../../ui/DataTable'
import Button from '../../ui/Button'
import Modal from '../../../ui/Modal'
import { DataTable } from '../../../ui/DataTable'
import Button from '../../../ui/Button'
import { useContext } from 'react'
import api from '../../api'
import { LocaleContext } from '../../contexts'
import { localeOption } from './CampaignDetail'
import { languageName } from '../../utils'
import api from '../../../api'
import { languageName } from '../../../utils'
import { useTranslation } from 'react-i18next'
import { TemplateContext } from '../../../contexts'
interface EditLocalesParams {
open: boolean
setIsOpen: (state: boolean) => void
campaign: Campaign
setCampaign: (campaign: Campaign) => void
setAddOpen: (state: boolean) => void
}
export default function EditLocalesModal({ open, setIsOpen, campaign, setCampaign, setAddOpen }: EditLocalesParams) {
export default function EditLocalesModal({ open, setIsOpen, setAddOpen }: EditLocalesParams) {
const { t } = useTranslation()
const [{ allLocales }, setLocale] = useContext(LocaleContext)
const {
campaign,
setCampaign,
locales,
} = useContext(TemplateContext)
async function handleRemoveLocale(locale: string) {
if (!confirm(t('remove_locale_warning'))) return
@ -29,12 +29,6 @@ export default function EditLocalesModal({ open, setIsOpen, campaign, setCampaig
const templates = campaign.templates.filter(template => template.id !== id)
const newCampaign = { ...campaign, templates }
setCampaign(newCampaign)
const template = templates[0]
setLocale({
currentLocale: template ? localeOption(template.locale) : undefined,
allLocales: allLocales.filter(item => item.key !== locale),
})
}
return (
@ -43,7 +37,7 @@ export default function EditLocalesModal({ open, setIsOpen, campaign, setCampaig
open={open}
onClose={() => setIsOpen(false)}>
<DataTable
items={allLocales}
items={locales}
itemKey={({ item }) => item.key}
columns={[
{

View file

@ -1,35 +1,35 @@
import { useContext, useState } from 'react'
import { LocaleContext } from '../../contexts'
import { Campaign, LocaleOption, UseStateContext } from '../../types'
import Button from '../../ui/Button'
import ButtonGroup from '../../ui/ButtonGroup'
import { SingleSelect } from '../../ui/form/SingleSelect'
import LocaleEditModal from './LocaleEditModal'
import { TemplateContext } from '../../../contexts'
import { Campaign, LocaleOption, UseStateContext } from '../../../types'
import Button from '../../../ui/Button'
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 '../TemplateCreateModal'
import { useTranslation } from 'react-i18next'
interface LocaleSelectorParams {
campaignState: UseStateContext<Campaign>
showAddState?: UseStateContext<boolean>
}
export default function LocaleSelector({
campaignState,
showAddState,
}: LocaleSelectorParams) {
export default function LocaleSelector({ showAddState }: LocaleSelectorParams) {
const { t } = useTranslation()
const [editOpen, setEditOpen] = useState(false)
const [addOpen, setAddOpen] = showAddState ?? useState(false)
const [campaign, setCampaign] = campaignState
const navigate = useNavigate()
const [{ currentLocale, allLocales }, setLocale] = useContext(LocaleContext)
const {
campaign,
setCampaign,
currentLocale,
locales,
setTemplate,
} = useContext(TemplateContext)
const handleTemplateCreate = async (campaign: Campaign, locale: LocaleOption) => {
setCampaign(campaign)
const locales = [...allLocales, locale]
setLocale({ currentLocale: locale, allLocales: locales })
handleLocaleSelect(locale)
if (campaign.templates.length === 1 && campaign.channel === 'email') {
await navigate('../editor')
@ -38,21 +38,25 @@ export default function LocaleSelector({
}
}
const handleLocaleSelect = (locale: LocaleOption) => {
setTemplate(campaign.templates.find(t => t.locale === locale.key))
}
return <>
<ButtonGroup>
{
currentLocale && (
<SingleSelect
options={allLocales}
options={locales}
size="small"
value={currentLocale}
onChange={(currentLocale) => setLocale({ currentLocale, allLocales })}
onChange={locale => handleLocaleSelect(locale)}
/>
)
}
{
campaign.state !== 'finished' && (
allLocales.length > 0
locales.length > 0
? <Button
size="small"
variant="secondary"
@ -66,11 +70,9 @@ export default function LocaleSelector({
)
}
</ButtonGroup>
<LocaleEditModal
<LocaleListModal
open={editOpen}
setIsOpen={setEditOpen}
campaign={campaign}
setCampaign={setCampaign}
setAddOpen={setAddOpen} />
<TemplateCreateModal
open={addOpen}

View file

@ -0,0 +1,48 @@
import { ReactNode, useContext } from 'react'
import { Link } from 'react-router'
import { Tag } from '../../../ui'
import { Journey, List } from '../../../types'
import { ProjectContext } from '../../../contexts'
interface DelimitedItemParams {
items?: any[]
delimiter?: ReactNode
mapper: (item: any) => { id: string | number, title: string, url: string }
}
const DelimitedItems = ({ items, delimiter = ' ', mapper }: DelimitedItemParams) => {
if (!items || items?.length === 0) return <>&#8211;</>
return <div className="tag-list">
{items?.map<ReactNode>(
item => {
const { id, title, url } = mapper(item)
return (
<Tag variant="plain" key={id}><Link to={url}>{title}</Link></Tag>
)
},
)?.reduce((prev, curr) => prev ? [prev, delimiter, curr] : curr, '')}
</div>
}
export const DelimitedJourneys = ({ journeys }: { journeys?: Journey[] }) => {
const [project] = useContext(ProjectContext)
return DelimitedItems({
items: journeys,
mapper: (journey) => ({
id: journey.id,
title: journey.name,
url: `/projects/${project.id}/journeys/${journey.id}`,
}),
})
}
export const DelimitedLists = ({ lists }: { lists?: List[] }) => {
const [project] = useContext(ProjectContext)
return DelimitedItems({
items: lists,
mapper: (list) => ({
id: list.id,
title: list.name,
url: `/projects/${project.id}/lists/${list.id}`,
}),
})
}

View file

@ -0,0 +1,65 @@
import { Campaign, Template, VariantUpdateParams } from '../../../types'
import FormWrapper from '../../../ui/form/FormWrapper'
import Modal from '../../../ui/Modal'
import { useContext } from 'react'
import { TemplateContext } from '../../../contexts'
import { useTranslation } from 'react-i18next'
import TextInput from '../../../ui/form/TextInput'
import api from '../../../api'
interface VariantFormParams {
variant?: VariantUpdateParams
onClose: () => void
campaign: Campaign
onCreate: (campaign: Campaign, template: Template) => void
}
export default function VariantFormModal({ variant, onClose, campaign, onCreate }: VariantFormParams) {
const { t } = useTranslation()
const { variants } = useContext(TemplateContext)
async function handleSubmitVariant(params: VariantUpdateParams) {
const clonedTemplate = variants[0]
const template = params.id
? await api.templates.update(campaign.project_id, params.id, { name: params.name, data: clonedTemplate.data })
: await api.templates.create(campaign.project_id, {
name: params.name,
campaign_id: campaign.id,
type: campaign.channel,
locale: clonedTemplate.locale,
data: clonedTemplate.data,
})
const newCampaign = { ...campaign }
const existing = newCampaign.templates.findIndex(t => t.id === template.id)
if (existing > -1) {
newCampaign.templates[existing] = template
} else {
newCampaign.templates.push(template)
}
onCreate(newCampaign, template)
onClose()
}
return (
<Modal title={variant?.id ? t('variant_update') : t('variant_create')}
open={!!variant}
onClose={() => onClose()}
zIndex={2000}>
<FormWrapper<VariantUpdateParams>
onSubmit={async (params) => { await handleSubmitVariant(params) }}
defaultValues={variant}
submitLabel={variant?.id ? t('variant_save') : t('variant_create')}>
{form => <>
<TextInput.Field
form={form}
name="name"
label={t('name')}
required />
</>}
</FormWrapper>
</Modal>
)
}

View file

@ -0,0 +1,83 @@
import { Campaign, Template, VariantUpdateParams } from '../../../types'
import Modal from '../../../ui/Modal'
import { DataTable } from '../../../ui/DataTable'
import Button from '../../../ui/Button'
import { useContext, useState } from 'react'
import api from '../../../api'
import { TemplateContext } from '../../../contexts'
import { useTranslation } from 'react-i18next'
import VariantFormModal from './VariantFormModal'
interface VariantEditParams {
open: boolean
setIsOpen: (state: boolean) => void
campaign: Campaign
setCampaign: (campaign: Campaign) => void
}
export default function VariantListModal({ open, setIsOpen, campaign, setCampaign }: VariantEditParams) {
const { t } = useTranslation()
const { variants, setTemplate } = useContext(TemplateContext)
const [editVariant, setEditVariant] = useState<VariantUpdateParams | undefined>()
const handleRemoveVariant = async (id: number) => {
if (!confirm(t('variant_remove_warning'))) return
await api.templates.delete(campaign.project_id, id)
const templates = campaign.templates.filter(template => template.id !== id)
const newCampaign = { ...campaign, templates }
setCampaign(newCampaign)
setTemplate(templates[0])
}
const handleCreateVariant = async (campaign: Campaign, template: Template) => {
setCampaign(campaign)
setTemplate(template)
setEditVariant(undefined)
}
return (
<Modal title={t('variants')}
description={t('variants_description')}
open={open}
onClose={() => setIsOpen(false)}>
<DataTable
items={variants}
itemKey={({ item }) => item.id}
onSelectRow={(item) => setEditVariant(item)}
columns={[
{
key: 'label',
title: t('variant'),
cell: ({ item }) => item.name,
},
{ key: 'locale', title: t('locale') },
{
key: 'options',
title: t('options'),
cell: ({ item }) => (
<Button
size="small"
variant="destructive"
onClick={async (event) => {
event.preventDefault()
event.stopPropagation()
await handleRemoveVariant(item.id)
}}>
{t('delete')}
</Button>
),
},
]} />
<div className="modal-footer">
<Button size="small" onClick={() => setEditVariant({ name: '' })}>{t('variant_add')}</Button>
</div>
<VariantFormModal
variant={editVariant}
onClose={() => setEditVariant(undefined)}
campaign={campaign}
onCreate={handleCreateVariant} />
</Modal>
)
}

View file

@ -0,0 +1,52 @@
import { useContext, useState } from 'react'
import { TemplateContext } from '../../../contexts'
import Button from '../../../ui/Button'
import ButtonGroup from '../../../ui/ButtonGroup'
import { SingleSelect } from '../../../ui/form/SingleSelect'
import { useTranslation } from 'react-i18next'
import VariantListModal from './VariantListModal'
export default function VariantSelector() {
const { t } = useTranslation()
const [isListOpen, setIsListOpen] = useState(false)
const {
campaign,
setCampaign,
currentTemplate,
variants,
setTemplate,
} = useContext(TemplateContext)
if (variants.length === 0) return null
return <>
<ButtonGroup>
{
variants.length > 1 && (
<SingleSelect
options={variants}
size="small"
value={currentTemplate}
getOptionDisplay={(variant) => variant.name ?? 'Control'}
onChange={(variant) => setTemplate(variant)}
/>
)
}
{
campaign.state !== 'finished' && (
<Button
size="small"
variant="secondary"
onClick={() => setIsListOpen(true)}
>{t('variants')}</Button>
)
}
</ButtonGroup>
<VariantListModal
open={isListOpen}
setIsOpen={(open) => setIsListOpen(open)}
campaign={campaign}
setCampaign={setCampaign} />
</>
}

View file

@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'
import { useCallback, useContext } from 'react'
import api from '../../../api'
import { Campaign, JourneyStepType, LocaleOption } from '../../../types'
import { Campaign, JourneyStepType } from '../../../types'
import { EntityIdPicker } from '../../../ui/form/EntityIdPicker'
import { ActionStepIcon } from '../../../ui/icons'
import { CampaignForm } from '../../campaign/CampaignForm'
@ -10,7 +10,8 @@ import { ChannelIcon } from '../../campaign/ChannelTag'
import Preview from '../../../ui/Preview'
import { SingleSelect } from '../../../ui/form/SingleSelect'
import { Heading, LinkButton } from '../../../ui'
import { locales } from '../../campaign/CampaignDetail'
import { TemplateContextProvider } from '../../campaign/TemplateContextProvider'
import { TemplateContext } from '../../../contexts'
interface ActionConfig {
campaign_id: number
@ -18,19 +19,23 @@ interface ActionConfig {
const JourneyTemplatePreview = ({ campaign }: { campaign: Campaign }) => {
const { t } = useTranslation()
const allLocales = locales(campaign.templates)
const [locale, setLocale] = useState<LocaleOption | undefined>(allLocales[0])
const template = campaign.templates.find(value => value.locale === locale?.key)
const { variants, locales, currentLocale, currentTemplate, setTemplate, setLocale } = useContext(TemplateContext)
return <>
<Heading
title={t('preview')}
size="h4"
actions={
<>
<SingleSelect
options={allLocales}
{variants.length > 1 && <SingleSelect
options={variants}
size="small"
value={locale}
value={currentTemplate}
onChange={(variant) => setTemplate(variant)}
/>}
<SingleSelect
options={locales}
size="small"
value={currentLocale}
onChange={(locale) => setLocale(locale)}
/>
<LinkButton
@ -43,7 +48,7 @@ const JourneyTemplatePreview = ({ campaign }: { campaign: Campaign }) => {
</>
}
/>
{template && <Preview template={template} />}
{currentTemplate && <Preview template={currentTemplate} />}
</>
}
@ -120,7 +125,9 @@ export const actionStep: JourneyStepType<ActionConfig> = {
)}
/>
{campaign && <JourneyTemplatePreview campaign={campaign} />}
{campaign && <TemplateContextProvider campaign={campaign} setCampaign={() => {}}>
<JourneyTemplatePreview campaign={campaign} />
</TemplateContextProvider>}
</>
)
},

View file

@ -196,7 +196,11 @@ export const createRouter = ({
children: [
{
index: true,
loader: async () => {
loader: async ({ params: { projectId = '' } }) => {
const project = await api.projects.get(projectId)
if (project.role === 'support') {
return redirect(`/projects/${project.id}/users`)
}
return redirect('campaigns')
},
},
@ -233,7 +237,7 @@ export const createRouter = ({
path: 'campaigns/:entityId/editor',
apiPath: api.campaigns,
context: CampaignContext,
element: (<EmailEditor />),
element: <EmailEditor />,
}),
createStatefulRoute({
path: 'journeys',

View file

@ -1,4 +1,4 @@
import { useCallback, useContext, useState } from 'react'
import { useCallback, useContext, useState, MouseEvent } from 'react'
import api from '../../api'
import { ProjectContext } from '../../contexts'
import { ProjectApiKey, projectRoles } from '../../types'
@ -30,10 +30,10 @@ export default function ProjectApiKeys() {
}
}
const handleCopy = async (event: React.MouseEvent<HTMLButtonElement, MouseEvent>, value: string) => {
await navigator.clipboard.writeText(value)
const handleCopy = async (event: MouseEvent<HTMLButtonElement>, value: string) => {
event.preventDefault()
event.stopPropagation()
await navigator.clipboard.writeText(value)
toast.success('Copied API Key')
}
@ -61,7 +61,7 @@ export default function ProjectApiKeys() {
cell: ({ item }) => (
<div className="cell-content">
{item.value}
<Button icon={<CopyIcon />} size="small" variant="plain" onClickCapture={async (e) => await handleCopy(e, item.value)} />
<Button icon={<CopyIcon />} size="small" variant="plain" onClick={async (e) => await handleCopy(e, item.value)} />
</div>
),
},