Create a wizard to send email messages

This commit is contained in:
Steven Nguyen
2023-12-19 16:15:29 -08:00
parent 8b953d4ccd
commit 96dc9612c8
10 changed files with 626 additions and 85 deletions
+2 -1
View File
@@ -290,5 +290,6 @@ export enum Submit {
SmsUpdateVerificationTemplate = 'submit_sms_update_verification_template',
MessagingProviderCreate = 'submit_messaging_provider_create',
MessagingProviderDelete = 'submit_messaging_provider_delete',
MessagingProviderUpdate = 'submit_messaging_provider_update'
MessagingProviderUpdate = 'submit_messaging_provider_update',
MessagingMessageCreate = 'submit_messaging_message_create'
}
@@ -1,5 +1,4 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/stores';
import {
@@ -21,16 +20,15 @@
TableCellHeadCheck,
TableCellText,
TableHeader,
TableRowLink
TableRowLink,
TableScroll
} from '$lib/elements/table';
import TableScroll from '$lib/elements/table/tableScroll.svelte';
import { toLocaleDateTime } from '$lib/helpers/date';
import { Container } from '$lib/layout';
import type { Models } from '@appwrite.io/console';
import type { PageData } from './$types';
import Create from './create.svelte';
import { columns, showCreate } from './store';
import MessageStatusPill from './messageStatusPill.svelte';
import CreateMessageDropdown from './createMessageDropdown.svelte';
import ProviderType, { ProviderTypes } from './providerType.svelte';
import Filters from '$lib/components/filters/filters.svelte';
import FailedModal from './failedModal.svelte';
@@ -40,13 +38,11 @@
let showDelete = false;
let showFailed = false;
let errors: string[] = [];
let showCreateDropdownMobile = false;
let showCreateDropdownDesktop = false;
let showCreateDropdownEmpty = false;
const project = $page.params.project;
async function messageCreated(event: CustomEvent<Models.Bucket>) {
$showCreate = false;
await goto(`${base}/console/project-${project}/messaging/message-${event.detail.$id}`);
}
</script>
<Container>
@@ -54,10 +50,7 @@
<div class="u-flex u-main-space-between">
<Heading tag="h2" size="5">Messages</Heading>
<div class="is-only-mobile">
<Button on:click={() => ($showCreate = true)} event="create_message">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create message</span>
</Button>
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownMobile} />
</div>
</div>
<!-- TODO: fix width of search input in mobile -->
@@ -71,10 +64,7 @@
hideView
allowNoColumns
showColsTextMobile />
<Button on:click={() => ($showCreate = true)} event="create_message">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create message</span>
</Button>
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownDesktop} />
</div>
</SearchQuery>
<div class="u-flex u-gap-16 is-only-mobile u-margin-block-start-16">
@@ -219,11 +209,35 @@
single
href="https://appwrite.io/docs"
target="message"
on:click={() => ($showCreate = true)} />
on:click={() => ($showCreate = true)}>
<div class="u-text-center">
<Heading size="7" tag="h2" trimmed={false}>
Create your first message to get started.
</Heading>
<p class="body-text-2 u-bold u-margin-block-start-4">
Need a hand? Learn more in our documentation.
</p>
</div>
<div class="u-flex u-flex-wrap u-gap-16 u-main-center">
<Button
external
href="https://appwrite.io/docs/references/cloud/client-web/messages"
text
event="empty_documentation"
ariaLabel={`create message`}>
Documentation
</Button>
<CreateMessageDropdown bind:showCreateDropdown={showCreateDropdownEmpty}>
<Button
secondary
on:click={() => (showCreateDropdownEmpty = !showCreateDropdownEmpty)}
event="create_message">
<span class="text">Create message</span>
</Button>
</CreateMessageDropdown>
</div>
</Empty>
{/if}
</Container>
<FailedModal bind:show={showFailed} {errors} />
<!-- TODO: handle create -->
<Create bind:showCreate={$showCreate} on:created={messageCreated} />
@@ -1,73 +1,111 @@
<script lang="ts">
import { Submit, trackEvent, trackError } from '$lib/actions/analytics';
import { Modal, CustomId } from '$lib/components';
import { Pill } from '$lib/elements';
import { Button, InputText, FormList } from '$lib/elements/forms';
import { addNotification } from '$lib/stores/notifications';
import { onDestroy } from 'svelte';
import { Wizard } from '$lib/layout';
import type { WizardStepsType } from '$lib/layout/wizard.svelte';
import Step1 from './wizard/step1.svelte';
import Step2 from './wizard/step2.svelte';
import Step3 from './wizard/step3.svelte';
import { sdk } from '$lib/stores/sdk';
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
import { addNotification } from '$lib/stores/notifications';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { project } from '../store';
import { wizard } from '$lib/stores/wizard';
import { providerType, messageParams } from './wizard/store';
import { ProviderTypes } from './providerType.svelte';
import { ID } from '@appwrite.io/console';
import { createEventDispatcher } from 'svelte';
export let showCreate = false;
const dispatch = createEventDispatcher();
let name = '';
let id: string = null;
let showCustomId = false;
let error: string;
const create = async () => {
async function create() {
try {
const bucket = await sdk.forProject.storage.createBucket(id ? id : ID.unique(), name);
showCreate = false;
dispatch('created', bucket);
let response = { $id: '' };
const messageId = $messageParams[$providerType].messageId || ID.unique();
switch ($providerType) {
case ProviderTypes.Email:
response = await sdk.forProject.client.call(
'POST',
new URL(
sdk.forProject.client.config.endpoint + '/messaging/messages/email'
),
{
'X-Appwrite-Project': sdk.forProject.client.config.project,
'content-type': 'application/json',
'X-Appwrite-Mode': 'admin'
},
{
...$messageParams[$providerType],
messageId
}
);
break;
case ProviderTypes.Sms:
response = await sdk.forProject.client.call(
'POST',
new URL(sdk.forProject.client.config.endpoint + '/messaging/messages/sms'),
{
'X-Appwrite-Project': sdk.forProject.client.config.project,
'content-type': 'application/json',
'X-Appwrite-Mode': 'admin'
},
{
...$messageParams[$providerType],
messageId
}
);
break;
case ProviderTypes.Push:
response = await sdk.forProject.client.call(
'POST',
new URL(
sdk.forProject.client.config.endpoint + '/messaging/providers/telesign'
),
{
'X-Appwrite-Project': sdk.forProject.client.config.project,
'content-type': 'application/json',
'X-Appwrite-Mode': 'admin'
},
{
...$messageParams[$providerType],
messageId
}
);
break;
}
wizard.hide();
addNotification({
type: 'success',
message: `${name} has been created`
message: `The message has been sent.`
});
name = null;
trackEvent(Submit.BucketCreate, {
customId: !!id
trackEvent(Submit.MessagingMessageCreate, {
providerType: $providerType
});
} catch (e) {
error = e.message;
trackError(e, Submit.BucketCreate);
await goto(`${base}/console/project-${$project.$id}/messaging/message-${response.$id}`);
} catch (error) {
addNotification({
type: 'error',
message: error.message
});
trackError(error, Submit.MessagingProviderCreate);
}
};
}
$: if (!showCustomId) {
id = null;
}
$: if (!showCreate) {
showCustomId = false;
error = null;
}
onDestroy(() => {
console.log('destroy');
});
const stepsComponents: WizardStepsType = new Map();
stepsComponents.set(1, {
label: 'Message',
component: Step1
});
stepsComponents.set(2, {
label: 'Targets',
component: Step2
});
stepsComponents.set(3, {
label: 'Schedule',
component: Step3
});
</script>
<Modal title="Create message" {error} onSubmit={create} size="big" bind:show={showCreate}>
<FormList>
<InputText
id="name"
label="Name"
placeholder="New bucket"
bind:value={name}
autofocus
required />
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}>
<span class="icon-pencil" aria-hidden="true" />
<span class="text"> Bucket ID </span>
</Pill>
</div>
{:else}
<CustomId bind:show={showCustomId} name="Bucket" bind:id />
{/if}
</FormList>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showCreate = false)}>Cancel</Button>
<Button submit>Create</Button>
</svelte:fragment>
</Modal>
<Wizard title="Create message" steps={stepsComponents} on:finish={create} finalAction="Send" />
@@ -0,0 +1,67 @@
<script lang="ts">
import { DropList, DropListItem } from '$lib/components';
import { Button } from '$lib/elements/forms';
import { wizard } from '$lib/stores/wizard';
import { providers } from './providers/store';
import Create from './create.svelte';
import { messageParams, providerType, targetsById } from './wizard/store';
import { ProviderTypes } from './providerType.svelte';
export let showCreateDropdown = false;
</script>
<DropList bind:show={showCreateDropdown} scrollable>
<slot>
<Button on:click={() => (showCreateDropdown = !showCreateDropdown)} event="create_message">
<span class="icon-plus" aria-hidden="true" />
<span class="text">Create message</span>
</Button>
</slot>
<svelte:fragment slot="list">
{#each Object.entries(providers) as [type, option]}
<DropListItem
icon={option.icon}
on:click={() => {
if (
type !== ProviderTypes.Email &&
type !== ProviderTypes.Sms &&
type !== ProviderTypes.Push
)
return;
$providerType = type;
$targetsById = {};
const common = {
topics: [],
users: [],
targets: []
};
switch (type) {
case ProviderTypes.Email:
$messageParams[$providerType] = {
...common,
subject: '',
content: ''
};
break;
case ProviderTypes.Sms:
$messageParams[$providerType] = {
...common,
content: ''
};
break;
case ProviderTypes.Push:
$messageParams[$providerType] = {
...common,
title: '',
body: ''
};
break;
}
showCreateDropdown = false;
wizard.start(Create);
}}>
{option.name}
</DropListItem>
{/each}
</svelte:fragment>
</DropList>
@@ -86,7 +86,9 @@
userResultsById = {};
response.users.forEach((user) => {
if (providerType !== null) {
user.targets = user.targets.filter((target) => target.providerType === providerType);
user.targets = user.targets.filter(
(target) => target.providerType === providerType
);
}
userResultsById = {
...userResultsById,
@@ -200,7 +202,9 @@
<svelte:fragment slot="description">
<div class="u-inline-flex u-gap-8">
<span class="inline-tag u-normal"
><ProviderType type={target.providerType} noIcon /></span>
><ProviderType
type={target.providerType}
noIcon /></span>
{#if target.providerType !== ProviderTypes.Push}
{target.identifier}
{:else}
@@ -0,0 +1,145 @@
<script context="module" lang="ts">
export async function createEmailMessage(params: EmailMessageParams) {
const response = await sdk.forProject.client.call(
'POST',
new URL(sdk.forProject.client.config.endpoint + '/messaging/messages/email'),
{
'X-Appwrite-Project': sdk.forProject.client.config.project,
'content-type': 'application/json',
'X-Appwrite-Mode': 'admin'
},
params
);
return response.json();
}
</script>
<script lang="ts">
import { messageParams, providerType, type EmailMessageParams, MessageStatuses } from './store';
import {
Button,
FormList,
InputEmail,
InputRadio,
InputSwitch,
InputText,
InputTextarea
} from '$lib/elements/forms';
import { Pill } from '$lib/elements';
import { CustomId, Modal } from '$lib/components';
import { user } from '$lib/stores/user';
import { clickOnEnter } from '$lib/helpers/a11y';
import { ID } from '@appwrite.io/console';
import { sdk } from '$lib/stores/sdk';
import { ProviderTypes } from '../providerType.svelte';
let showCustomId = false;
let showTest = false;
let selected = 'self';
let otherEmail = '';
async function sendTestEmail() {
const email = selected === 'self' ? $user.email : otherEmail;
console.log(email);
createEmailMessage({
topics: $messageParams[ProviderTypes.Email]?.topics || [],
targets: $messageParams[ProviderTypes.Email]?.targets || [],
description: $messageParams[ProviderTypes.Email]?.description || 'Test message',
status: MessageStatuses.PROCESSING,
messageId: ID.unique(),
// TODO: properly handle the test email address
users: ['steven'],
subject: $messageParams[ProviderTypes.Email]?.subject || '',
content: $messageParams[ProviderTypes.Email]?.content || '',
html: $messageParams[ProviderTypes.Email]?.html || false
});
}
$: otherEmail = selected === 'self' ? '' : otherEmail;
</script>
<FormList>
<InputText
id="subject"
label="Subject"
placeholder="Enter subject"
bind:value={$messageParams[$providerType]['subject']}>
</InputText>
<div class="u-colum-gap-2">
<InputTextarea
id="message"
label="Message"
placeholder="Type here..."
bind:value={$messageParams[$providerType]['content']}>
</InputTextarea>
<!-- TODO: Add support for draft messages -->
<!-- <div class="u-flex u-main-end">
<Button text on:click={() => (showTest = true)}>Send test message</Button>
</div> -->
<Modal title="Send test message" bind:show={showTest} onSubmit={sendTestEmail} size="big">
<slot />
<InputRadio
label={$user.email}
bind:group={selected}
value="self"
id="self"
name="selected" />
<InputRadio
label="Other"
bind:group={selected}
value="other"
id="other"
name="selected"
fullWidth>
<svelte:fragment slot="description">
Enter the email address to which te test message will be
<div
on:click={() => (selected = 'other')}
on:keyup|self={clickOnEnter}
role="button"
tabindex="0">
<InputEmail
showLabel={false}
id="email"
label="Email"
placeholder="Enter email"
bind:value={otherEmail} />
</div>
</svelte:fragment>
</InputRadio>
<svelte:fragment slot="footer">
<Button secondary on:click={() => (showTest = false)}>Cancel</Button>
<Button submit>Send</Button>
</svelte:fragment>
</Modal>
</div>
<InputSwitch label="HTML mode" id="html" bind:value={$messageParams[$providerType]['html']}>
<svelte:fragment slot="description">
Enable the HTML mode if your message contains HTML tags.
</svelte:fragment>
</InputSwitch>
<InputText
id="description"
label="Description"
placeholder="Enter description"
tooltip="Provide a summary of the message. Users won't see this description."
bind:value={$messageParams[$providerType]['description']}>
</InputText>
{#if !showCustomId}
<div>
<Pill button on:click={() => (showCustomId = !showCustomId)}
><span class="icon-pencil" aria-hidden="true" /><span class="text">
Message ID
</span></Pill>
</div>
{:else}
<CustomId
bind:show={showCustomId}
name="Message"
bind:id={$messageParams[$providerType].messageId}
autofocus={false} />
{/if}
</FormList>
@@ -0,0 +1,20 @@
<script lang="ts">
import { WizardStep } from '$lib/layout';
import { messageParams, providerType } from './store';
import { providers } from '../providers/store';
import EmailFormList from './emailFormList.svelte';
async function beforeSubmit() {
console.log($messageParams[$providerType]);
}
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Message</svelte:fragment>
<svelte:fragment slot="subtitle">
<!-- TODO: update documentation link -->
Create an {providers[$providerType].text} that will be displayed to your subscribers. Learn more
in our documentation.
</svelte:fragment>
<EmailFormList />
</WizardStep>
@@ -0,0 +1,99 @@
<script lang="ts">
import { Card } from '$lib/components';
import UserTargetsModal, { type Target } from '../userTargetsModal.svelte';
import { ProviderTypes } from '../providerType.svelte';
import { Button } from '$lib/elements/forms';
import {
Table,
TableBody,
TableCell,
TableCellHead,
TableHeader,
TableRow
} from '$lib/elements/table';
import { WizardStep } from '$lib/layout';
import { messageParams, providerType, targetsById } from './store';
let showDropdown = false;
let targetIdsLength = 0;
function update(event: CustomEvent<Record<string, Target>>) {
$targetsById = event.detail;
showDropdown = false;
}
function removeTarget(targetId: string) {
const { [targetId]: _, ...rest } = $targetsById;
$targetsById = rest;
}
async function beforeSubmit() {
$messageParams[$providerType].targets = Object.keys($targetsById);
console.log($messageParams[$providerType]);
}
$: targetIdsLength = Object.keys($targetsById).length;
$: console.log(targetIdsLength);
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Targets</svelte:fragment>
<!-- TODO: add support for topics -->
<svelte:fragment slot="subtitle"
>Select users to whom this message should be directed.</svelte:fragment>
{#if targetIdsLength == 0}
<Card>
<div class="u-flex u-cross-center u-flex-vertical u-main-center u-flex">
<Button secondary round on:click={() => (showDropdown = !showDropdown)}>
<i class="icon-plus" />
</Button>
<div class="common-section">
<span class="text"> Select recipients to get started</span>
</div>
</div>
</Card>
{:else}
<div class="table-wrapper">
<Table noMargin noStyles>
<TableHeader>
<TableCellHead width={140}>Target</TableCellHead>
<TableCellHead width={32} />
</TableHeader>
<TableBody>
{#each Object.entries($targetsById) as [targetId, target] (targetId)}
<TableRow>
<TableCell title="Target">
<div class="u-flex u-cross-center">
<span class="text"
>{target.name ? target.name : target.identifier}</span>
</div>
</TableCell>
<TableCell title="Remove" width={40}>
<div class="u-flex u-main-end">
<button
class="button is-text is-only-icon"
type="button"
aria-label="delete"
on:click={() => removeTarget(targetId)}>
<span class="icon-x" aria-hidden="true" />
</button>
</div>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
<Button text noMargin on:click={() => (showDropdown = !showDropdown)}>
<span class="icon-plus" aria-hidden="true" />
<span class="text">Add target</span>
</Button>
{/if}
</WizardStep>
<UserTargetsModal
providerType={ProviderTypes.Email}
bind:show={showDropdown}
bind:targetsById={$targetsById}
on:update={update} />
@@ -0,0 +1,101 @@
<script lang="ts">
import { FormList, InputDate, InputSelect, InputTime } from '$lib/elements/forms';
import Helper from '$lib/elements/forms/helper.svelte';
import { WizardStep } from '$lib/layout';
import { messageParams, providerType } from './store';
let when: 'now' | 'later' = 'now';
let now = new Date();
let timeZoneOffset: number;
let minDate: string;
let date: string;
let time: string;
let dateTime: Date;
const options = [
{ label: 'Now', value: 'now' },
{ label: 'Schedule', value: 'later' }
];
const formatOptions: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hourCycle: 'h23',
timeZoneName: 'longOffset'
};
async function beforeSubmit() {
if (when === 'later') {
$messageParams[$providerType].scheduledAt = dateTime.toISOString();
}
console.log($messageParams[$providerType]);
}
$: if (when === 'now') {
date = time = '';
}
$: if (when === 'later') {
now = new Date();
}
$: timeZoneOffset = now ? now.getTimezoneOffset() * 60 * 1000 : 0;
$: minDate = new Date(now.getTime() - timeZoneOffset).toISOString().split('T')[0];
$: minTime =
date === minDate
? new Date(now.getTime() - timeZoneOffset).toISOString().split('T')[1].substring(0, 5)
: '00:00';
$: dateTime = new Date(`${date}T${time}`);
</script>
<WizardStep {beforeSubmit}>
<svelte:fragment slot="title">Schedule</svelte:fragment>
<!-- TODO: Add link to docs -->
<svelte:fragment slot="subtitle"
>Schedule the time you want your users to receive this message. Learn more in our
documentation.</svelte:fragment>
<FormList>
<div
class="u-grid u-gap-24"
style="grid-auto-rows: 1fr; grid-template-columns: 1fr 1fr 1fr;">
<InputSelect id="when" label="&nbsp;" {options} bind:value={when} />
<InputDate
id="date"
label="Date"
disabled={when === 'now'}
required={when === 'later'}
min={minDate}
bind:value={date} />
<InputTime
id="time"
label="Time"
disabled={when === 'now'}
required={when === 'later'}
min={minTime}
bind:value={time} />
</div>
</FormList>
<Helper type="neutral">
{#if when === 'now'}
The message will be sent immediately
{:else if !dateTime || isNaN(dateTime.getTime())}
The message will be sent later
{:else}
The message will be sent at {dateTime.toLocaleString('en', formatOptions)}
{/if}
</Helper>
</WizardStep>
<!-- <Modal
title="Send message"
bind:show={showDelete}
onSubmit={deleteAccount}
icon="exclamation"
state="warning"
headerDivider={false}>
<p>Are you sure you want to delete your account?</p>
<svelte:fragment slot="footer">
<Button text on:click={() => (showDelete = false)}>Cancel</Button>
<Button secondary submit>Delete</Button>
</svelte:fragment>
</Modal> -->
@@ -0,0 +1,52 @@
import { ProviderTypes } from '../providerType.svelte';
import type { Target } from '../userTargetsModal.svelte';
import { writable } from 'svelte/store';
export enum MessageStatuses {
DRAFT = 'draft',
PROCESSING = 'processing'
}
export type MessageParams = {
messageId: string;
topics: string[];
users: string[];
targets: string[];
description: string;
status: MessageStatuses;
scheduledAt?: string;
};
export type EmailMessageParams = MessageParams & {
subject: string;
content: string;
html: boolean;
};
export type SMSMessageParams = MessageParams & {
content: string;
};
export type PushMessageParams = MessageParams & {
title: string;
body: string;
data: Record<string, string>;
action?: string;
icon?: string;
sound?: string;
color?: string;
tag?: string;
badge?: string;
};
export const providerType = writable<ProviderTypes>(null);
export const targetsById = writable<Record<string, Target>>({});
export const messageParams = writable<{
[ProviderTypes.Email]: Partial<EmailMessageParams>;
[ProviderTypes.Sms]: Partial<SMSMessageParams>;
[ProviderTypes.Push]: Partial<PushMessageParams>;
}>({
[ProviderTypes.Email]: null,
[ProviderTypes.Sms]: null,
[ProviderTypes.Push]: null
});