diff --git a/apps/platform/src/projects/ProjectService.ts b/apps/platform/src/projects/ProjectService.ts index 63429967..c8157628 100644 --- a/apps/platform/src/projects/ProjectService.ts +++ b/apps/platform/src/projects/ProjectService.ts @@ -39,6 +39,7 @@ export const createProject = async (adminId: number, params: ProjectParams) => { await createSubscription(project.id, { name: 'Default Email', channel: 'email' }) await createSubscription(project.id, { name: 'Default SMS', channel: 'text' }) await createSubscription(project.id, { name: 'Default Push', channel: 'push' }) + await createSubscription(project.id, { name: 'Default Webhook', channel: 'webhook' }) return project } diff --git a/apps/platform/src/providers/webhook/LocalWebhookProvider.ts b/apps/platform/src/providers/webhook/LocalWebhookProvider.ts index 2e9d65f9..0e70421d 100644 --- a/apps/platform/src/providers/webhook/LocalWebhookProvider.ts +++ b/apps/platform/src/providers/webhook/LocalWebhookProvider.ts @@ -20,11 +20,23 @@ export default class LocalWebhookProvider extends WebhookProvider { const { method, endpoint, headers, body } = options const response = await fetch(endpoint, { method, - headers, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + }, body: JSON.stringify(body), }) - const responseBody = await response.json() + let responseBody: any | undefined + try { + responseBody = await response.json() + } catch { + try { + responseBody = await response.text() + } catch {} + } + if (response.ok) { return { message: options, diff --git a/apps/platform/src/render/TemplateController.ts b/apps/platform/src/render/TemplateController.ts index 443018e7..8afc5411 100644 --- a/apps/platform/src/render/TemplateController.ts +++ b/apps/platform/src/render/TemplateController.ts @@ -92,6 +92,26 @@ const templateDataPushParams = { nullable: true, } +const templateDataWebhookParams = { + type: 'object', + required: ['method', 'endpoint'], + properties: { + method: { type: 'string' }, + endpoint: { type: 'string' }, + body: { + type: 'object', + nullable: true, + additionalProperties: true, + }, + headers: { + type: 'object', + nullable: true, + additionalProperties: true, + }, + }, + nullable: true, +} + const templateCreateParams: JSONSchemaType = { $id: 'templateCreateParams', oneOf: [{ @@ -147,6 +167,24 @@ const templateCreateParams: JSONSchemaType = { data: templateDataPushParams as any, }, additionalProperties: false, + }, + { + type: 'object', + required: ['type', 'campaign_id', 'locale'], + properties: { + type: { + type: 'string', + enum: ['webhook'], + }, + campaign_id: { + type: 'integer', + }, + locale: { + type: 'string', + }, + data: templateDataWebhookParams as any, + }, + additionalProperties: false, }], } router.post('/', async ctx => { @@ -204,9 +242,22 @@ const templateUpdateParams: JSONSchemaType = { data: templateDataPushParams as any, }, additionalProperties: false, + }, + { + type: 'object', + required: ['type', 'data'], + properties: { + type: { + type: 'string', + enum: ['webhook'], + }, + data: templateDataWebhookParams as any, + }, + additionalProperties: false, }], } router.patch('/:templateId', async ctx => { + console.log(ctx.request.body) const payload = validate(templateUpdateParams, ctx.request.body) ctx.body = await updateTemplate(ctx.state.template!.id, payload) }) diff --git a/apps/platform/src/render/TemplateService.ts b/apps/platform/src/render/TemplateService.ts index 34900b12..4518aad6 100644 --- a/apps/platform/src/render/TemplateService.ts +++ b/apps/platform/src/render/TemplateService.ts @@ -11,6 +11,7 @@ import { RequestError } from '../core/errors' import CampaignError from '../campaigns/CampaignError' import { loadPushChannel } from '../providers/push' import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository' +import { loadWebhookChannel } from '../providers/webhook' export const pagedTemplates = async (params: SearchParams, projectId: number) => { return await Template.searchParams( @@ -95,6 +96,13 @@ export const sendProof = async (template: TemplateType, variables: Variables, re event, context, }) + } else if (template.type === 'webhook') { + const channel = await loadWebhookChannel(campaign.provider_id, projectId) + await channel?.send(template, { + user: User.fromJson({ ...variables.user, data: variables.user }), + event, + context, + }) } else { throw new RequestError('Sending template proofs is only supported for email and text message types as this time.') } diff --git a/apps/ui/src/contexts.ts b/apps/ui/src/contexts.ts index e62fd736..f2e511c7 100644 --- a/apps/ui/src/contexts.ts +++ b/apps/ui/src/contexts.ts @@ -1,6 +1,5 @@ import { createContext, Dispatch, SetStateAction } from 'react' -import { Admin, Campaign, Journey, List, Project, Template, User, UseStateContext } from './types' -import { FieldOption } from './ui/form/Field' +import { Admin, Campaign, Journey, List, LocaleOption, Project, Template, User, UseStateContext } from './types' export const AdminContext = createContext(null) @@ -15,8 +14,8 @@ export const JourneyContext = createContext>([ ]) export interface LocaleSelection { - currentLocale?: FieldOption - allLocales: FieldOption[] + currentLocale?: LocaleOption + allLocales: LocaleOption[] } export const LocaleContext = createContext>([ { allLocales: [] }, diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 79c74b9e..39c00700 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -452,3 +452,8 @@ export interface Metric { date: string | Date count: number } + +export interface LocaleOption { + key: string + label: string +} diff --git a/apps/ui/src/ui/Preview.css b/apps/ui/src/ui/Preview.css index 41d1b2cf..40d7c59e 100644 --- a/apps/ui/src/ui/Preview.css +++ b/apps/ui/src/ui/Preview.css @@ -142,4 +142,8 @@ .push-notification .notification-body { grid-area: body; +} + +.webhook-frame { + padding: 20px 10px; } \ No newline at end of file diff --git a/apps/ui/src/ui/Preview.tsx b/apps/ui/src/ui/Preview.tsx index c1b429d6..6c600fd1 100644 --- a/apps/ui/src/ui/Preview.tsx +++ b/apps/ui/src/ui/Preview.tsx @@ -1,3 +1,4 @@ +import { JsonViewer } from '@textea/json-viewer' import { format } from 'date-fns' import { Template } from '../types' import Iframe from './Iframe' @@ -39,6 +40,12 @@ export default function Preview({ template }: PreviewProps) { + const WebhookFrame = () => ( +
+ +
+ ) + return (
{ @@ -46,7 +53,7 @@ export default function Preview({ template }: PreviewProps) { email: , text: , push: , - webhook: <>, + webhook: , }[type] }
diff --git a/apps/ui/src/ui/form/EntityIdPicker.tsx b/apps/ui/src/ui/form/EntityIdPicker.tsx index f700a179..b4101860 100644 --- a/apps/ui/src/ui/form/EntityIdPicker.tsx +++ b/apps/ui/src/ui/form/EntityIdPicker.tsx @@ -1,5 +1,5 @@ -import { Combobox, Transition } from '@headlessui/react' -import { useCallback, useState, Fragment, RefCallback, ReactNode } from 'react' +import { Combobox } from '@headlessui/react' +import { useCallback, useState, RefCallback, ReactNode } from 'react' import { useResolver } from '../../hooks' import { ControlledInputProps, SearchResult } from '../../types' import clsx from 'clsx' @@ -132,45 +132,33 @@ export function EntityIdPicker({ ) } -
- - - { - result?.results.map((option) => ( - clsx( - 'select-option', - active && 'active', - disabled && 'disabled', - selected && 'selected', - )} - disabled={!optionEnabled(option)} - > - - {displayValue(option)} - - - - - )) - } - - -
+ { + result?.results.map((option) => ( + clsx( + 'select-option', + active && 'active', + disabled && 'disabled', + selected && 'selected', + )} + disabled={!optionEnabled(option)} + > + + {displayValue(option)} + + + + + )) + } + ) } diff --git a/apps/ui/src/ui/form/FormWrapper.tsx b/apps/ui/src/ui/form/FormWrapper.tsx index 7c6d3859..c748329c 100644 --- a/apps/ui/src/ui/form/FormWrapper.tsx +++ b/apps/ui/src/ui/form/FormWrapper.tsx @@ -28,6 +28,7 @@ export default function FormWrapper({ const handleSubmit = form.handleSubmit(async data => { setIsLoading(true) onSubmit(data, navigate).finally(() => { + console.log('saved!', data) setIsLoading(false) }) }) @@ -36,6 +37,8 @@ export default function FormWrapper({ const keys = Object.keys(errors) if (keys.length === 0) return undefined + console.log('error 1') + const key = keys[0] const error = errors[key] if (error) { diff --git a/apps/ui/src/ui/form/JsonField.tsx b/apps/ui/src/ui/form/JsonField.tsx new file mode 100644 index 00000000..b2de7df7 --- /dev/null +++ b/apps/ui/src/ui/form/JsonField.tsx @@ -0,0 +1,35 @@ +import TextInput, { TextInputProps } from './TextInput' +import { FieldProps } from '../../types' +import { FieldPath, FieldValues, useController } from 'react-hook-form' +import { useState } from 'react' + +export default function JsonField>({ + form, + name, + required, + ...rest +}: Omit, 'onChange' | 'onBlur' | 'value'> & FieldProps) { + const { field: { ref, value, ...field }, fieldState } = useController({ + control: form.control, + name, + rules: { + required, + }, + }) + const [jsonValue, setJsonValue] = useState(JSON.stringify(value)) + + return ( + { + setJsonValue(value) + await field.onChange?.(JSON.parse(value)) + }} + required={required} + error={fieldState.error?.message} + /> + ) +} diff --git a/apps/ui/src/ui/form/MultiSelect.tsx b/apps/ui/src/ui/form/MultiSelect.tsx index cd5ac04e..0b3e8a23 100644 --- a/apps/ui/src/ui/form/MultiSelect.tsx +++ b/apps/ui/src/ui/form/MultiSelect.tsx @@ -1,4 +1,4 @@ -import { Listbox, Transition } from '@headlessui/react' +import { Listbox } from '@headlessui/react' import { Fragment, ReactNode } from 'react' import { CheckIcon, ChevronUpDownIcon } from '../icons' import { FieldPath, FieldValues, useController } from 'react-hook-form' @@ -131,43 +131,31 @@ export function MultiSelect({ ) } -
- - - {options.map((option) => { - const value = toValue(option) - return ( - clsx( - 'select-option', - active && 'active', - selected && 'selected', - )} - > - {getOptionDisplay(option)} - - - - ) - })} - {optionsFooter} - - -
+ {options.map((option) => { + const value = toValue(option) + return ( + clsx( + 'select-option', + active && 'active', + selected && 'selected', + )} + > + {getOptionDisplay(option)} + + + + ) + })} + {optionsFooter} + ) } diff --git a/apps/ui/src/ui/form/Select.css b/apps/ui/src/ui/form/Select.css index 7d700325..43ee8f83 100644 --- a/apps/ui/src/ui/form/Select.css +++ b/apps/ui/src/ui/form/Select.css @@ -96,6 +96,7 @@ max-height: 275px; overflow: scroll; margin: 0; + z-index: 999; } .ui-select .select-option { diff --git a/apps/ui/src/ui/form/SingleSelect.tsx b/apps/ui/src/ui/form/SingleSelect.tsx index 6669ea10..4276d32d 100644 --- a/apps/ui/src/ui/form/SingleSelect.tsx +++ b/apps/ui/src/ui/form/SingleSelect.tsx @@ -1,5 +1,5 @@ -import { Listbox, Transition } from '@headlessui/react' -import { Fragment, ReactNode } from 'react' +import { Listbox } from '@headlessui/react' +import { ReactNode } from 'react' import { CheckIcon, ChevronUpDownIcon } from '../icons' import { FieldPath, FieldValues, useController } from 'react-hook-form' import './Select.css' @@ -98,43 +98,31 @@ export function SingleSelect({ ) } -
- - - {options.map((option) => { - const value = toValue(option) - return ( - clsx( - 'select-option', - active && 'active', - selected && 'selected', - )} - > - {getOptionDisplay(option)} - - - - ) - })} - {optionsFooter} - - -
+ {options.map((option) => { + const value = toValue(option) + return ( + clsx( + 'select-option', + active && 'active', + selected && 'selected', + )} + > + {getOptionDisplay(option)} + + + + ) + })} + {optionsFooter} + ) } diff --git a/apps/ui/src/views/campaign/CampaignDetail.tsx b/apps/ui/src/views/campaign/CampaignDetail.tsx index a50fb114..9aecc484 100644 --- a/apps/ui/src/views/campaign/CampaignDetail.tsx +++ b/apps/ui/src/views/campaign/CampaignDetail.tsx @@ -5,21 +5,21 @@ import { NavigationTabs } from '../../ui/Tabs' import { useContext, useEffect, useState } from 'react' import { CampaignContext, LocaleContext, LocaleSelection, ProjectContext } from '../../contexts' import { languageName } from '../../utils' -import { FieldOption } from '../../ui/form/Field' -import { Campaign, Template } from '../../types' +import { Campaign, LocaleOption, Template } from '../../types' import api from '../../api' import { CampaignTag } from './Campaigns' import LaunchCampaign from './LaunchCampaign' import { ForbiddenIcon, RestartIcon, SendIcon } from '../../ui/icons' -export const locales = (templates: Template[]) => templates?.map(item => { - const language = languageName(item.locale) - const locale = item.locale ?? '' +export const localeOption = (locale: string): LocaleOption => { + const language = languageName(locale) return { - key: item.locale, + key: locale, label: language ? `${language} (${locale})` : locale, - } satisfies FieldOption -}) + } +} + +export const locales = (templates: Template[]) => templates?.map(item => localeOption(item.locale)) const localeState = (templates: Template[]) => { const allLocales = locales(templates) diff --git a/apps/ui/src/views/campaign/CampaignPreview.tsx b/apps/ui/src/views/campaign/CampaignPreview.tsx index c471f71f..3146e58c 100644 --- a/apps/ui/src/views/campaign/CampaignPreview.tsx +++ b/apps/ui/src/views/campaign/CampaignPreview.tsx @@ -118,7 +118,9 @@ export default function CampaignPreview() { recipient, }) setIsSendProofOpen(false) - toast.success('Template proof has been successfully sent!') + template.type === 'webhook' + ? toast.success('Webhook test has been successfully sent!') + : toast.success('Template proof has been successfully sent!') } return ( @@ -150,10 +152,15 @@ export default function CampaignPreview() { setIsSendProofOpen(true)}>Send Proof + template.type === 'webhook' + ? + : } /> diff --git a/apps/ui/src/views/campaign/CreateLocaleModal.tsx b/apps/ui/src/views/campaign/CreateLocaleModal.tsx index 1010f541..92fe7488 100644 --- a/apps/ui/src/views/campaign/CreateLocaleModal.tsx +++ b/apps/ui/src/views/campaign/CreateLocaleModal.tsx @@ -1,17 +1,17 @@ -import { Campaign } from '../../types' +import { Campaign, LocaleOption } from '../../types' import TextInput from '../../ui/form/TextInput' import FormWrapper from '../../ui/form/FormWrapper' import Modal from '../../ui/Modal' import { languageName } from '../../utils' import { UseFormReturn } from 'react-hook-form' -import { createLocale } from './CampaignDetail' +import { createLocale, localeOption } from './CampaignDetail' import { useState } from 'react' interface CreateLocaleParams { open: boolean setIsOpen: (state: boolean) => void campaign: Campaign - setCampaign: (campaign: Campaign) => void + onCreate: (campaign: Campaign, locale: LocaleOption) => void } const LocaleTextField = ({ form }: { form: UseFormReturn<{ locale: string }> }) => { @@ -35,13 +35,13 @@ const LocaleTextField = ({ form }: { form: UseFormReturn<{ locale: string }> }) } -export default function CreateLocaleModal({ open, setIsOpen, campaign, setCampaign }: CreateLocaleParams) { +export default function CreateLocaleModal({ open, setIsOpen, campaign, onCreate }: CreateLocaleParams) { async function handleCreateLocale(locale: string) { const template = await createLocale(locale, campaign) const newCampaign = { ...campaign } newCampaign.templates.push(template) - setCampaign(newCampaign) + onCreate(newCampaign, localeOption(locale)) setIsOpen(false) } diff --git a/apps/ui/src/views/campaign/EditLocalesModal.tsx b/apps/ui/src/views/campaign/EditLocalesModal.tsx index d958c221..11bbd0e3 100644 --- a/apps/ui/src/views/campaign/EditLocalesModal.tsx +++ b/apps/ui/src/views/campaign/EditLocalesModal.tsx @@ -59,7 +59,7 @@ export default function EditLocalesModal({ open, setIsOpen, campaign, setCampaig open={showAddLocale} setIsOpen={setShowAddLocale} campaign={campaign} - setCampaign={setCampaign} /> + onCreate={setCampaign} /> ) } diff --git a/apps/ui/src/views/campaign/EmailEditor.tsx b/apps/ui/src/views/campaign/EmailEditor.tsx index c874b43c..6917eda0 100644 --- a/apps/ui/src/views/campaign/EmailEditor.tsx +++ b/apps/ui/src/views/campaign/EmailEditor.tsx @@ -205,7 +205,7 @@ export default function EmailEditor() { open={false} setIsOpen={() => setIsAddLocaleOpen(false)} campaign={campaign} - setCampaign={setCampaign} /> + onCreate={setCampaign} /> ) } diff --git a/apps/ui/src/views/campaign/LocaleSelector.tsx b/apps/ui/src/views/campaign/LocaleSelector.tsx index df6326b1..eff358f7 100644 --- a/apps/ui/src/views/campaign/LocaleSelector.tsx +++ b/apps/ui/src/views/campaign/LocaleSelector.tsx @@ -1,7 +1,7 @@ import { useContext } from 'react' import { useNavigate } from 'react-router-dom' import { LocaleContext } from '../../contexts' -import { Campaign, UseStateContext } from '../../types' +import { Campaign, LocaleOption, UseStateContext } from '../../types' import Button from '../../ui/Button' import ButtonGroup from '../../ui/ButtonGroup' import { SingleSelect } from '../../ui/form/SingleSelect' @@ -19,8 +19,10 @@ export default function LocaleSelector({ campaignState, openState }: LocaleSelec const navigate = useNavigate() const [{ currentLocale, allLocales }, setLocale] = useContext(LocaleContext) - const handleCampaignCreate = (campaign: Campaign) => { + const handleTemplateCreate = (campaign: Campaign, locale: LocaleOption) => { setCampaign(campaign) + setLocale({ currentLocale: locale, allLocales }) + if (campaign.templates.length === 1 && campaign.channel === 'email') { navigate('../editor') } @@ -54,7 +56,7 @@ export default function LocaleSelector({ campaignState, openState }: LocaleSelec open={open} setIsOpen={setOpen} campaign={campaign} - setCampaign={handleCampaignCreate} + onCreate={handleTemplateCreate} /> } diff --git a/apps/ui/src/views/campaign/TemplateDetail.tsx b/apps/ui/src/views/campaign/TemplateDetail.tsx index 6beeaa5c..72f8bbc0 100644 --- a/apps/ui/src/views/campaign/TemplateDetail.tsx +++ b/apps/ui/src/views/campaign/TemplateDetail.tsx @@ -8,9 +8,11 @@ import Preview from '../../ui/Preview' import { InfoTable } from '../../ui/InfoTable' import Modal from '../../ui/Modal' import FormWrapper from '../../ui/form/FormWrapper' -import { EmailTemplateData, PushTemplateData, Template, TemplateUpdateParams, TextTemplateData } from '../../types' +import { EmailTemplateData, PushTemplateData, Template, TemplateUpdateParams, TextTemplateData, WebhookTemplateData } from '../../types' import TextInput from '../../ui/form/TextInput' import api from '../../api' +import { SingleSelect } from '../../ui/form/SingleSelect' +import JsonField from '../../ui/form/JsonField' const EmailTable = ({ data }: { data: EmailTemplateData }) => }) required /> +const WebhookTable = ({ data }: { data: WebhookTemplateData }) => + +const WebhookForm = ({ form }: { form: UseFormReturn }) => <> + + + + + + interface TemplateDetailProps { template: Template } @@ -113,7 +146,7 @@ export default function TemplateDetail({ template }: TemplateDetailProps) { email: , text: , push: , - webhook: <>, + webhook: , }[type] } @@ -141,7 +174,7 @@ export default function TemplateDetail({ template }: TemplateDetailProps) { email: , text: , push: , - webhook: <>, + webhook: , }[type] } }