Use this page when people write or edit content that they need to see rendered as they work, such as an in-app message or the body of an email. The fields sit beside a live preview of the current values, and the page header keeps Discard changes, Save draft and Publish in reach. Use a settings form instead when there is nothing to preview.
Changes show in the preview as you type. People see them once you publish.
Pick a plan to keep sending campaigns and broadcasts without a break. Your people, segments and messages stay exactly as they are.
import {
Box,
Button,
FormLayout,
FormLayoutGroup,
Heading,
Page,
PageBody,
PageHeader,
PageHeaderActions,
PageHeaderDescription,
PageHeaderIconTile,
PageHeaderTitle,
Panel,
PanelInset,
PanelSection,
Paragraph,
Select,
SplitView,
SplitViewPane,
SplitViewResizer,
Stack,
TextArea,
TextField,
} from '@customerio/pluma-components/react';
export type EditorWithPreviewButtonStyle = 'primary' | 'secondary';
const buttonStyleOptions: { value: EditorWithPreviewButtonStyle; label: string }[] = [
{ value: 'primary', label: 'Filled' },
{ value: 'secondary', label: 'Outlined' },
];
export interface EditorWithPreviewValues {
title: string;
body: string;
buttonLabel: string;
buttonLink: string;
buttonStyle: EditorWithPreviewButtonStyle;
}
/** An error message per field, for the fields that failed validation. */
export type EditorWithPreviewErrors = Partial<Record<'title' | 'body' | 'buttonLabel' | 'buttonLink', string>>;
export interface EditorWithPreviewProps {
/** The message's name, shown as the page title. */
name: string;
/** The current value of every field. The preview shows these values. */
values: EditorWithPreviewValues;
/** Errors to show under the fields. */
errors: EditorWithPreviewErrors;
/** Whether the values differ from the last saved draft. Saving and discarding are off until they do. */
hasUnsavedChanges: boolean;
/** Called on every keystroke in the title field. */
onTitleInput: (event: Event & { target: HTMLInputElement }) => void;
/** Called on every keystroke in the body field. */
onBodyInput: (event: Event & { target: HTMLTextAreaElement }) => void;
/** Called on every keystroke in the button label field. */
onButtonLabelInput: (event: Event & { target: HTMLInputElement }) => void;
/** Called on every keystroke in the button link field. */
onButtonLinkInput: (event: Event & { target: HTMLInputElement }) => void;
/** Called with the button style the user picks. */
onButtonStyleChange: (buttonStyle: string | null) => void;
/** Called when the user discards their changes. Confirm first, then go back to the last saved draft. */
onDiscard: () => void;
/** Called when the user saves a draft. Return a promise to show the button as loading until it settles. */
onSave: () => void | Promise<void>;
/**
* Called when the user publishes the message, unsaved changes included. Return a promise to show the button as
* loading until it settles.
*/
onPublish: () => void | Promise<void>;
}
export function EditorWithPreview({
name,
values,
errors,
hasUnsavedChanges,
onTitleInput,
onBodyInput,
onButtonLabelInput,
onButtonLinkInput,
onButtonStyleChange,
onDiscard,
onSave,
onPublish,
}: EditorWithPreviewProps) {
return (
<Page isFullScreen>
<PageHeader>
<PageHeaderIconTile name="in-app" />
<PageHeaderTitle>{name}</PageHeaderTitle>
<PageHeaderActions>
<Button variant="tertiary" isDisabled={!hasUnsavedChanges} onClick={onDiscard}>
Discard changes
</Button>
<Button variant="secondary" isDisabled={!hasUnsavedChanges} onClick={onSave}>
Save draft
</Button>
<Button variant="primary" onClick={onPublish}>
Publish
</Button>
</PageHeaderActions>
<PageHeaderDescription>
Changes show in the preview as you type. People see them once you publish.
</PageHeaderDescription>
</PageHeader>
<PageBody>
<Panel>
<PanelInset overflow="hidden">
<SplitView>
<SplitViewPane minSize="50%">
<Box p="400">
<Stack gap="400">
<Heading level="2" text="product-h4">
Message
</Heading>
<FormLayout>
<TextField label="Title" value={values.title} error={errors.title} onInput={onTitleInput} />
<TextArea
label="Body"
minRows={4}
value={values.body}
error={errors.body}
onInput={onBodyInput}
/>
<FormLayoutGroup>
<TextField
label="Button label"
value={values.buttonLabel}
error={errors.buttonLabel}
onInput={onButtonLabelInput}
/>
<Select
label="Button style"
options={buttonStyleOptions}
value={values.buttonStyle}
onChange={onButtonStyleChange}
/>
</FormLayoutGroup>
<TextField
label="Button link"
description="A web address or a deep link into your app."
type="url"
value={values.buttonLink}
error={errors.buttonLink}
onInput={onButtonLinkInput}
/>
</FormLayout>
</Stack>
</Box>
</SplitViewPane>
<SplitViewResizer />
<SplitViewPane defaultSize="40%" maxSize="50%">
<Box backgroundColor="recessed" p="400" h="full">
<Stack gap="400">
<Heading level="2" text="product-h4">
Preview
</Heading>
<Panel>
<PanelSection>
<Stack gap="300" alignInline="start">
{values.title === '' ? null : <Heading level="3">{values.title}</Heading>}
{values.body === '' ? null : <Paragraph whiteSpace="pre-line">{values.body}</Paragraph>}
{values.buttonLabel === '' ? null : (
<Button variant={values.buttonStyle}>{values.buttonLabel}</Button>
)}
</Stack>
</PanelSection>
</Panel>
</Stack>
</Box>
</SplitViewPane>
</SplitView>
</PanelInset>
</Panel>
</PageBody>
</Page>
);
}
Copy this recipe into your app with the Pluma CLI. Replace <dest-dir> with the folder the file goes in, and add --name <Name> to rename the component and its file.
pluma-cli scaffold editor-with-preview <dest-dir> --framework reactThis recipe uses these components. Each one's docs page lists its props.