mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
feat: campaign experimentation (#695)
This commit is contained in:
parent
8e5a8b43b8
commit
c65bb196ea
40 changed files with 932 additions and 467 deletions
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 <>–</>
|
||||
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'
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
67
apps/ui/src/views/campaign/TemplateContextProvider.tsx
Normal file
67
apps/ui/src/views/campaign/TemplateContextProvider.tsx
Normal 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>
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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 => <>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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'>
|
|
@ -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
|
|
@ -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'
|
||||
|
|
@ -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'
|
||||
|
|
74
apps/ui/src/views/campaign/launch/DateTimeField.tsx
Normal file
74
apps/ui/src/views/campaign/launch/DateTimeField.tsx
Normal 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>
|
||||
}
|
148
apps/ui/src/views/campaign/launch/LaunchCampaign.tsx
Normal file
148
apps/ui/src/views/campaign/launch/LaunchCampaign.tsx
Normal 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>
|
||||
}
|
|
@ -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={[
|
||||
{
|
|
@ -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}
|
48
apps/ui/src/views/campaign/ui/DelimitedItems.tsx
Normal file
48
apps/ui/src/views/campaign/ui/DelimitedItems.tsx
Normal 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 <>–</>
|
||||
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}`,
|
||||
}),
|
||||
})
|
||||
}
|
65
apps/ui/src/views/campaign/variants/VariantFormModal.tsx
Normal file
65
apps/ui/src/views/campaign/variants/VariantFormModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
83
apps/ui/src/views/campaign/variants/VariantListModal.tsx
Normal file
83
apps/ui/src/views/campaign/variants/VariantListModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
52
apps/ui/src/views/campaign/variants/VariantSelector.tsx
Normal file
52
apps/ui/src/views/campaign/variants/VariantSelector.tsx
Normal 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} />
|
||||
</>
|
||||
}
|
|
@ -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>}
|
||||
</>
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
),
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue