Skip to content

Commit 1596fe5

Browse files
authored
Improved welcome email user flow for editing and activating (#26035)
closes https://linear.app/ghost/issue/NY-842/editing-viewing-email-before-setting-to-active - previously a user needed to toggle a welcome email to active before they could edit it because the button was hidden. this meant there was a scenario where a new member could sign up before the admin was done editing, and get the default welcome email text - this PR updates the UI so that the welcome email can be edited separately from activating it - also some improvements for accessibility, markup semantics, and keyboard interactivity
1 parent 3ecb770 commit 1596fe5

File tree

4 files changed

+495
-95
lines changed

4 files changed

+495
-95
lines changed

apps/admin-x-settings/src/components/settings/membership/member-emails.tsx

Lines changed: 154 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import NiceModal from '@ebay/nice-modal-react';
33
import React from 'react';
44
import TopLevelGroup from '../../top-level-group';
55
import WelcomeEmailModal from './member-emails/welcome-email-modal';
6-
import {Button, Separator, SettingGroupContent, Toggle, withErrorBoundary} from '@tryghost/admin-x-design-system';
6+
import {Separator, SettingGroupContent, Toggle, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
77
import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
88
import {useAddAutomatedEmail, useBrowseAutomatedEmails, useEditAutomatedEmail} from '@tryghost/admin-x-framework/api/automated-emails';
99
import {useGlobalData} from '../../providers/global-data-provider';
@@ -18,10 +18,18 @@ const DEFAULT_PAID_LEXICAL_CONTENT = '{"root":{"children":[{"children":[{"detail
1818

1919
const EmailPreview: React.FC<{
2020
automatedEmail: AutomatedEmail,
21-
emailType: 'free' | 'paid'
21+
emailType: 'free' | 'paid',
22+
enabled: boolean,
23+
isInitialLoading: boolean,
24+
onEdit: () => void,
25+
onToggle: () => void
2226
}> = ({
2327
automatedEmail,
24-
emailType
28+
emailType,
29+
enabled,
30+
isInitialLoading,
31+
onEdit,
32+
onToggle
2533
}) => {
2634
const {settings} = useGlobalData();
2735
const [accentColor, icon, siteTitle] = getSettingValues<string>(settings, ['accent_color', 'icon', 'title']);
@@ -30,34 +38,60 @@ const EmailPreview: React.FC<{
3038
const senderName = automatedEmail.sender_name || siteTitle || 'Your Site';
3139

3240
return (
33-
<div className='mb-5 flex items-center justify-between gap-3 rounded-lg border border-grey-100 bg-grey-50 p-5 dark:border-grey-925 dark:bg-grey-975'>
34-
<div className='flex items-start gap-3'>
35-
{icon ?
36-
<div className='size-10 min-h-10 min-w-10 rounded-sm bg-cover bg-center' style={{
37-
backgroundImage: `url(${icon})`
38-
}} />
39-
:
40-
<div className='flex aspect-square size-10 items-center justify-center overflow-hidden rounded-full p-1 text-white' style={{
41-
backgroundColor: color
42-
}}>
43-
<img className='h-auto w-8' src={FakeLogo} />
41+
<div
42+
className='relative flex w-full items-center justify-between gap-6 rounded-lg border border-grey-100 bg-grey-50 p-5 text-left transition-all hover:border-grey-200 hover:shadow-sm dark:border-grey-925 dark:bg-grey-975 dark:hover:border-grey-800'
43+
data-testid={`${emailType}-welcome-email-preview`}
44+
>
45+
<button
46+
className='flex w-full cursor-pointer items-center justify-between before:absolute before:inset-0 before:rounded-lg before:content-[""] focus-visible:outline-none focus-visible:before:ring-2 focus-visible:before:ring-green'
47+
type='button'
48+
onClick={onEdit}
49+
>
50+
<div className='flex items-start gap-3'>
51+
{icon ?
52+
<div className='size-10 min-h-10 min-w-10 rounded-sm bg-cover bg-center' style={{
53+
backgroundImage: `url(${icon})`
54+
}} />
55+
:
56+
<div className='flex aspect-square size-10 items-center justify-center overflow-hidden rounded-full p-1 text-white' style={{
57+
backgroundColor: color
58+
}}>
59+
<img alt="" className='h-auto w-8' src={FakeLogo} />
60+
</div>
61+
}
62+
<div className='text-left'>
63+
<div className='font-semibold'>{senderName}</div>
64+
<div className='text-sm'>{automatedEmail.subject}</div>
4465
</div>
45-
}
46-
<div>
47-
<div className='font-semibold'>{senderName}</div>
48-
<div className='text-sm'>{automatedEmail.subject}</div>
4966
</div>
67+
<div className='text-sm font-semibold opacity-100 transition-all hover:opacity-80'>
68+
Edit
69+
</div>
70+
</button>
71+
<div className='relative z-10 rounded-full has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-green'>
72+
{isInitialLoading ? (
73+
<div className="h-4 w-7 rounded-full bg-grey-300 dark:bg-grey-800" />
74+
) : (
75+
<Toggle
76+
checked={enabled}
77+
onChange={onToggle}
78+
/>
79+
)}
80+
</div>
81+
</div>
82+
);
83+
};
84+
85+
const EmailSettingRow: React.FC<{
86+
title: string,
87+
description: string
88+
}> = ({title, description}) => {
89+
return (
90+
<div className='flex items-center justify-between py-4'>
91+
<div>
92+
<div className='font-medium'>{title}</div>
93+
<div className='text-sm text-grey-700 dark:text-grey-600'>{description}</div>
5094
</div>
51-
<Button
52-
className='border border-grey-200 font-semibold hover:border-grey-300 dark:border-grey-900 dark:hover:border-grey-800 dark:hover:bg-grey-950'
53-
color='white'
54-
data-testid={`${emailType}-welcome-email-edit-button`}
55-
icon='pen'
56-
label='Edit'
57-
onClick={() => {
58-
NiceModal.show(WelcomeEmailModal, {emailType, automatedEmail});
59-
}}
60-
/>
6195
</div>
6296
);
6397
};
@@ -67,51 +101,101 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
67101
const [siteTitle] = getSettingValues<string>(settings, ['title']);
68102

69103
const {data: automatedEmailsData, isLoading} = useBrowseAutomatedEmails();
70-
const {mutateAsync: addAutomatedEmail} = useAddAutomatedEmail();
71-
const {mutateAsync: editAutomatedEmail} = useEditAutomatedEmail();
104+
const {mutateAsync: addAutomatedEmail, isLoading: isAddingAutomatedEmail} = useAddAutomatedEmail();
105+
const {mutateAsync: editAutomatedEmail, isLoading: isEditingAutomatedEmail} = useEditAutomatedEmail();
72106
const handleError = useHandleError();
73107

74108
const automatedEmails = automatedEmailsData?.automated_emails || [];
109+
const isMutating = isAddingAutomatedEmail || isEditingAutomatedEmail;
110+
const isBusy = isLoading || isMutating;
75111

76112
const freeWelcomeEmail = automatedEmails.find(email => email.slug === 'member-welcome-email-free');
77113
const paidWelcomeEmail = automatedEmails.find(email => email.slug === 'member-welcome-email-paid');
78114

79115
const freeWelcomeEmailEnabled = freeWelcomeEmail?.status === 'active';
80116
const paidWelcomeEmailEnabled = paidWelcomeEmail?.status === 'active';
81117

118+
// Helper to get default values for an email type
119+
const getDefaultEmailValues = (emailType: 'free' | 'paid') => ({
120+
name: emailType === 'free' ? 'Welcome Email (Free)' : 'Welcome Email (Paid)',
121+
slug: `member-welcome-email-${emailType}`,
122+
subject: emailType === 'free'
123+
? `Welcome to ${siteTitle || 'our site'}`
124+
: 'Welcome to your paid subscription',
125+
lexical: emailType === 'free' ? DEFAULT_FREE_LEXICAL_CONTENT : DEFAULT_PAID_LEXICAL_CONTENT
126+
});
127+
128+
// Create default email objects for display when no DB row exists
129+
const getDefaultEmail = (emailType: 'free' | 'paid'): AutomatedEmail => ({
130+
id: '',
131+
status: 'inactive',
132+
...getDefaultEmailValues(emailType),
133+
sender_name: null,
134+
sender_email: null,
135+
sender_reply_to: null,
136+
created_at: '',
137+
updated_at: null
138+
});
139+
140+
// Create a new automated email row with the given status
141+
const createAutomatedEmail = async (emailType: 'free' | 'paid', status: 'active' | 'inactive') => {
142+
const defaults = getDefaultEmailValues(emailType);
143+
return addAutomatedEmail({...defaults, status});
144+
};
145+
82146
const handleToggle = async (emailType: 'free' | 'paid') => {
83147
const slug = `member-welcome-email-${emailType}`;
84148
const existing = automatedEmails.find(email => email.slug === slug);
149+
const label = emailType === 'free' ? 'Free members' : 'Paid members';
85150

86-
const defaultSubject = emailType === 'free'
87-
? `Welcome to ${siteTitle || 'our site'}`
88-
: 'Welcome to your paid subscription';
151+
if (isBusy) {
152+
return;
153+
}
89154

90155
try {
91156
if (!existing) {
92-
// First toggle ON - create with defaults
93-
const defaultContent = emailType === 'free'
94-
? DEFAULT_FREE_LEXICAL_CONTENT
95-
: DEFAULT_PAID_LEXICAL_CONTENT;
96-
await addAutomatedEmail({
97-
name: emailType === 'free' ? 'Welcome Email (Free)' : 'Welcome Email (Paid)',
98-
slug: slug,
99-
subject: defaultSubject,
100-
status: 'active',
101-
lexical: defaultContent
102-
});
157+
await createAutomatedEmail(emailType, 'active');
158+
showToast({type: 'success', title: `${label} welcome email enabled`});
103159
} else if (existing.status === 'active') {
104-
// Toggle OFF
105160
await editAutomatedEmail({...existing, status: 'inactive'});
161+
showToast({type: 'success', title: `${label} welcome email disabled`});
106162
} else {
107-
// Toggle ON (re-enable)
108163
await editAutomatedEmail({...existing, status: 'active'});
164+
showToast({type: 'success', title: `${label} welcome email enabled`});
109165
}
110166
} catch (e) {
111167
handleError(e);
112168
}
113169
};
114170

171+
// Handle Edit button click - creates inactive row if needed, then opens modal
172+
const handleEditClick = async (emailType: 'free' | 'paid') => {
173+
const slug = `member-welcome-email-${emailType}`;
174+
const existing = automatedEmails.find(email => email.slug === slug);
175+
176+
if (isBusy) {
177+
return;
178+
}
179+
180+
if (!existing) {
181+
try {
182+
const result = await createAutomatedEmail(emailType, 'inactive');
183+
const newEmail = result?.automated_emails?.[0];
184+
if (newEmail) {
185+
NiceModal.show(WelcomeEmailModal, {emailType, automatedEmail: newEmail});
186+
}
187+
} catch (e) {
188+
handleError(e);
189+
}
190+
} else {
191+
NiceModal.show(WelcomeEmailModal, {emailType, automatedEmail: existing});
192+
}
193+
};
194+
195+
// Get email to display (existing or default for preview)
196+
const freeEmailForDisplay = freeWelcomeEmail || getDefaultEmail('free');
197+
const paidEmailForDisplay = paidWelcomeEmail || getDefaultEmail('paid');
198+
115199
return (
116200
<TopLevelGroup
117201
description="Create and manage automated emails for your members"
@@ -122,46 +206,33 @@ const MemberEmails: React.FC<{ keywords: string[] }> = ({keywords}) => {
122206
>
123207
<SettingGroupContent className="!gap-y-0" columns={1}>
124208
<Separator />
125-
<Toggle
126-
key={`free-${isLoading ? 'loading' : freeWelcomeEmail?.status ?? 'none'}`}
127-
checked={Boolean(freeWelcomeEmailEnabled)}
128-
containerClasses='items-center'
129-
direction='rtl'
130-
disabled={isLoading}
131-
gap='gap-0'
132-
hint='Sent to new free members right after they join your site.'
133-
label='Free members'
134-
labelClasses='py-4 w-full'
135-
onChange={() => handleToggle('free')}
209+
<EmailSettingRow
210+
description='Email new free members receive when they join your site.'
211+
title='Free members welcome email'
212+
/>
213+
<EmailPreview
214+
automatedEmail={freeEmailForDisplay}
215+
emailType='free'
216+
enabled={freeWelcomeEmailEnabled}
217+
isInitialLoading={isLoading}
218+
onEdit={() => handleEditClick('free')}
219+
onToggle={() => handleToggle('free')}
136220
/>
137-
{freeWelcomeEmail && freeWelcomeEmailEnabled &&
138-
<EmailPreview
139-
automatedEmail={freeWelcomeEmail}
140-
emailType='free'
141-
/>
142-
}
143221
{checkStripeEnabled(settings, config) && (
144-
<>
145-
<Separator />
146-
<Toggle
147-
key={`paid-${isLoading ? 'loading' : paidWelcomeEmail?.status ?? 'none'}`}
148-
checked={Boolean(paidWelcomeEmailEnabled)}
149-
containerClasses='items-center'
150-
direction='rtl'
151-
disabled={isLoading}
152-
gap='gap-0'
153-
hint='Sent to new paid members right after they start their subscription.'
154-
label='Paid members'
155-
labelClasses='py-4 w-full'
156-
onChange={() => handleToggle('paid')}
222+
<div className='mt-4'>
223+
<EmailSettingRow
224+
description='Sent to new paid members as soon as they start their subscription.'
225+
title='Paid members welcome email'
157226
/>
158-
{paidWelcomeEmail && paidWelcomeEmailEnabled &&
159-
<EmailPreview
160-
automatedEmail={paidWelcomeEmail}
161-
emailType='paid'
162-
/>
163-
}
164-
</>
227+
<EmailPreview
228+
automatedEmail={paidEmailForDisplay}
229+
emailType='paid'
230+
enabled={paidWelcomeEmailEnabled}
231+
isInitialLoading={isLoading}
232+
onEdit={() => handleEditClick('paid')}
233+
onToggle={() => handleToggle('paid')}
234+
/>
235+
</div>
165236
)}
166237
</SettingGroupContent>
167238
</TopLevelGroup>

0 commit comments

Comments
 (0)