Skip to content

Commit bcd968a

Browse files
committed
Added newsletter selection to bulk member unsubscribe
ref https://linear.app/tryghost/issue/BER-3380/ When bulk-unsubscribing members, the modal now lets you choose between unsubscribing from all newsletters or selecting specific ones via an inline searchable picker. When only one newsletter is active, the modal shows a simple confirmation instead.
1 parent c144a8f commit bcd968a

File tree

3 files changed

+263
-23
lines changed

3 files changed

+263
-23
lines changed

apps/admin-x-framework/src/api/members.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ export interface BulkEditAction {
182182
meta?: {
183183
label?: {id: string};
184184
};
185+
newsletter?: string | null;
185186
}
186187

187188
export interface BulkOperationResponseType {
@@ -204,7 +205,8 @@ export const useBulkEditMembers = createMutation<
204205
body: ({action}) => ({
205206
bulk: {
206207
action: action.type,
207-
meta: action.meta || {}
208+
meta: action.meta || {},
209+
newsletter: action.newsletter
208210
}
209211
}),
210212
searchParams: ({filter, all}) => {

apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx

Lines changed: 201 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,237 @@
11
import {
2+
Badge,
23
Button,
4+
Command,
5+
CommandCheck,
6+
CommandEmpty,
7+
CommandItem,
8+
CommandList,
39
Dialog,
410
DialogContent,
5-
DialogDescription,
611
DialogFooter,
712
DialogHeader,
8-
DialogTitle
13+
DialogTitle,
14+
LucideIcon
915
} from '@tryghost/shade';
16+
import {Newsletter} from '@tryghost/admin-x-framework/api/newsletters';
17+
import {useCallback, useEffect, useRef, useState} from 'react';
18+
19+
type UnsubscribeMode = 'all' | 'selected';
1020

1121
interface UnsubscribeModalProps {
1222
open: boolean;
23+
newsletters: Newsletter[];
1324
memberCount: number;
1425
onOpenChange: (open: boolean) => void;
15-
onConfirm: () => void;
26+
onConfirm: (newsletterIds: string[] | null) => void;
1627
isLoading?: boolean;
1728
}
1829

1930
export function UnsubscribeModal({
2031
open,
32+
newsletters,
2133
memberCount,
2234
onOpenChange,
2335
onConfirm,
2436
isLoading = false
2537
}: UnsubscribeModalProps) {
38+
const [mode, setMode] = useState<UnsubscribeMode>('all');
39+
const [selectedIds, setSelectedIds] = useState<string[]>([]);
40+
const [search, setSearch] = useState('');
41+
const [listOpen, setListOpen] = useState(false);
42+
const pickerRef = useRef<HTMLDivElement>(null);
43+
const inputRef = useRef<HTMLInputElement>(null);
44+
45+
// Reset state when dialog is closed externally (e.g. parent sets open=false after confirm)
46+
useEffect(() => {
47+
if (!open) {
48+
setMode('all');
49+
setSelectedIds([]);
50+
setSearch('');
51+
setListOpen(false);
52+
}
53+
}, [open]);
54+
55+
// Close list on click outside the picker
56+
useEffect(() => {
57+
if (!listOpen) {
58+
return;
59+
}
60+
const handlePointerDown = (e: PointerEvent) => {
61+
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
62+
setListOpen(false);
63+
}
64+
};
65+
document.addEventListener('pointerdown', handlePointerDown);
66+
return () => document.removeEventListener('pointerdown', handlePointerDown);
67+
}, [listOpen]);
68+
69+
const handleOpenChange = (isOpen: boolean) => {
70+
if (!isOpen) {
71+
setMode('all');
72+
setSelectedIds([]);
73+
setSearch('');
74+
setListOpen(false);
75+
}
76+
onOpenChange(isOpen);
77+
};
78+
79+
const toggleNewsletter = useCallback((id: string) => {
80+
setSelectedIds((prev) => {
81+
return prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id];
82+
});
83+
}, []);
84+
85+
const showPicker = newsletters.length >= 2;
86+
87+
const handleConfirm = () => {
88+
if (!showPicker || mode === 'all') {
89+
onConfirm(null);
90+
} else {
91+
onConfirm(selectedIds);
92+
}
93+
};
94+
95+
const isDisabled = isLoading || (showPicker && mode === 'selected' && selectedIds.length === 0);
96+
const memberLabel = memberCount === 1 ? 'member' : 'members';
97+
2698
return (
27-
<Dialog open={open} onOpenChange={onOpenChange}>
99+
<Dialog open={open} onOpenChange={handleOpenChange}>
28100
<DialogContent className="gap-5">
29101
<DialogHeader>
30102
<DialogTitle>Unsubscribe members</DialogTitle>
31-
<DialogDescription>
32-
Are you sure you want to unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} from all newsletters?
33-
They will no longer receive any email newsletters from you.
34-
</DialogDescription>
35103
</DialogHeader>
36104

105+
{showPicker ? (
106+
<>
107+
<div aria-label="Unsubscribe scope" className="flex flex-col gap-3" role="radiogroup">
108+
<label className="flex cursor-pointer items-start gap-3">
109+
<input
110+
checked={mode === 'all'}
111+
className="mt-0.5 size-4 cursor-pointer accent-black"
112+
name="unsubscribe-mode"
113+
type="radio"
114+
value="all"
115+
onChange={() => setMode('all')}
116+
/>
117+
<div className="flex flex-col">
118+
<span className="text-sm font-semibold">Unsubscribe from all newsletters</span>
119+
<span className="text-sm text-muted-foreground">
120+
{memberCount.toLocaleString()} {memberLabel} will be unsubscribed from {newsletters.length} {newsletters.length === 1 ? 'newsletter' : 'newsletters'}.
121+
</span>
122+
</div>
123+
</label>
124+
125+
<label className="flex cursor-pointer items-start gap-3">
126+
<input
127+
checked={mode === 'selected'}
128+
className="mt-0.5 size-4 cursor-pointer accent-black"
129+
name="unsubscribe-mode"
130+
type="radio"
131+
value="selected"
132+
onChange={() => setMode('selected')}
133+
/>
134+
<div className="flex flex-col">
135+
<span className="text-sm font-semibold">Unsubscribe from selected newsletters</span>
136+
<span className="text-sm text-muted-foreground">
137+
Select which newsletters to unsubscribe {memberCount.toLocaleString()} {memberLabel} from.
138+
</span>
139+
</div>
140+
</label>
141+
</div>
142+
143+
{mode === 'selected' && (
144+
<div ref={pickerRef} className="relative space-y-2">
145+
<label className="text-sm font-semibold" htmlFor="newsletter-search">Newsletters</label>
146+
<div
147+
className="flex min-h-9 w-full cursor-text flex-wrap items-center gap-1.5 rounded-md border bg-background px-3 py-1 text-sm"
148+
onClick={() => {
149+
inputRef.current?.focus();
150+
setListOpen(true);
151+
}}
152+
>
153+
{selectedIds.map((id) => {
154+
const nl = newsletters.find(n => n.id === id);
155+
if (!nl) {
156+
return null;
157+
}
158+
return (
159+
<Badge
160+
key={id}
161+
className="cursor-pointer gap-1 pr-1"
162+
variant="outline"
163+
onClick={(e) => {
164+
e.stopPropagation();
165+
toggleNewsletter(id);
166+
}}
167+
>
168+
{nl.name}
169+
<LucideIcon.X className="size-3" />
170+
</Badge>
171+
);
172+
})}
173+
<input
174+
ref={inputRef}
175+
className="min-w-[80px] flex-1 bg-transparent py-1 text-sm outline-none placeholder:text-muted-foreground"
176+
id="newsletter-search"
177+
placeholder={selectedIds.length === 0 ? 'Search newsletters...' : ''}
178+
value={search}
179+
onChange={(e) => {
180+
setSearch(e.target.value);
181+
if (!listOpen) {
182+
setListOpen(true);
183+
}
184+
}}
185+
onFocus={() => setListOpen(true)}
186+
onKeyDown={(e) => {
187+
if (e.key === 'Escape') {
188+
setListOpen(false);
189+
inputRef.current?.blur();
190+
}
191+
if (e.key === 'Backspace' && !search && selectedIds.length > 0) {
192+
toggleNewsletter(selectedIds[selectedIds.length - 1]);
193+
}
194+
}}
195+
/>
196+
</div>
197+
{listOpen && (
198+
<div className="absolute left-0 top-full z-50 mt-1 w-full rounded-md border bg-white shadow-md dark:bg-gray-950">
199+
<Command shouldFilter={false}>
200+
<CommandList className="max-h-64 overflow-y-auto">
201+
<CommandEmpty>No newsletters found.</CommandEmpty>
202+
{newsletters
203+
.filter(n => n.name.toLowerCase().includes(search.toLowerCase()))
204+
.map(newsletter => (
205+
<CommandItem
206+
key={newsletter.id}
207+
value={newsletter.name}
208+
onSelect={() => toggleNewsletter(newsletter.id)}
209+
>
210+
{newsletter.name}
211+
{selectedIds.includes(newsletter.id) && <CommandCheck />}
212+
</CommandItem>
213+
))}
214+
</CommandList>
215+
</Command>
216+
</div>
217+
)}
218+
</div>
219+
)}
220+
</>
221+
) : (
222+
<p className="text-sm text-muted-foreground">
223+
Are you sure you want to unsubscribe {memberCount.toLocaleString()} {memberLabel} from all newsletters? They will no longer receive any email newsletters from you.
224+
</p>
225+
)}
226+
37227
<DialogFooter>
38-
<Button variant="outline" onClick={() => onOpenChange(false)}>
228+
<Button variant="outline" onClick={() => handleOpenChange(false)}>
39229
Cancel
40230
</Button>
41231
<Button
42-
disabled={isLoading}
232+
disabled={isDisabled}
43233
variant="destructive"
44-
onClick={onConfirm}
234+
onClick={handleConfirm}
45235
>
46236
{isLoading ? 'Unsubscribing...' : 'Unsubscribe'}
47237
</Button>

apps/posts/src/views/members/components/members-actions.tsx

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@tryghost/shade';
1212
import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers';
1313
import {toast} from 'sonner';
14+
import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters';
1415
import {useBulkDeleteMembers, useBulkEditMembers} from '@tryghost/admin-x-framework/api/members';
1516

1617
interface MembersActionsProps {
@@ -35,8 +36,14 @@ const MembersActions: React.FC<MembersActionsProps> = ({
3536
nql,
3637
canBulkDelete
3738
}) => {
39+
const {data: newslettersData, isLoading: isLoadingNewsletters} = useBrowseNewsletters({
40+
searchParams: {filter: 'status:-archived', limit: '50'}
41+
});
42+
const activeNewsletters = newslettersData?.newsletters || [];
43+
3844
const {mutateAsync: bulkEditAsync, isLoading: isBulkEditing} = useBulkEditMembers();
3945
const {mutate: bulkDelete, isLoading: isBulkDeleting} = useBulkDeleteMembers();
46+
const [isUnsubscribing, setIsUnsubscribing] = useState(false);
4047

4148
const [showAddLabelModal, setShowAddLabelModal] = useState(false);
4249
const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false);
@@ -96,21 +103,58 @@ const MembersActions: React.FC<MembersActionsProps> = ({
96103
}
97104
}, [bulkEditAsync, nql]);
98105

99-
const handleUnsubscribe = useCallback(() => {
100-
bulkEditAsync({
106+
const handleUnsubscribe = useCallback(async (newsletterIds: string[] | null) => {
107+
const baseParams = {
101108
filter: nql || '',
102-
all: !nql,
103-
action: {
104-
type: 'unsubscribe'
109+
all: !nql
110+
};
111+
112+
if (newsletterIds === null) {
113+
try {
114+
await bulkEditAsync({
115+
...baseParams,
116+
action: {type: 'unsubscribe'}
117+
});
118+
setShowUnsubscribeModal(false);
119+
toast.success('Members unsubscribed successfully');
120+
} catch {
121+
toast.error('Failed to unsubscribe members', {
122+
description: 'There was a problem unsubscribing these members. Please try again.'
123+
});
105124
}
106-
}).then(() => {
125+
return;
126+
}
127+
128+
setIsUnsubscribing(true);
129+
try {
130+
const results = await Promise.allSettled(
131+
newsletterIds.map(id => bulkEditAsync({
132+
...baseParams,
133+
action: {type: 'unsubscribe', newsletter: id}
134+
}))
135+
);
136+
const succeeded = results.filter(r => r.status === 'fulfilled').length;
137+
const total = results.length;
138+
107139
setShowUnsubscribeModal(false);
108-
toast.success('Members unsubscribed successfully');
109-
}).catch(() => {
140+
if (succeeded === total) {
141+
toast.success(`Unsubscribed from ${total} ${total === 1 ? 'newsletter' : 'newsletters'}`);
142+
} else if (succeeded > 0) {
143+
toast.warning(`Unsubscribed from ${succeeded} of ${total} newsletters`, {
144+
description: 'Some newsletters could not be unsubscribed. Please try again.'
145+
});
146+
} else {
147+
toast.error('Failed to unsubscribe members', {
148+
description: 'There was a problem unsubscribing these members. Please try again.'
149+
});
150+
}
151+
} catch {
110152
toast.error('Failed to unsubscribe members', {
111153
description: 'There was a problem unsubscribing these members. Please try again.'
112154
});
113-
});
155+
} finally {
156+
setIsUnsubscribing(false);
157+
}
114158
}, [bulkEditAsync, nql]);
115159

116160
const handleDelete = useCallback(() => {
@@ -168,7 +212,10 @@ const MembersActions: React.FC<MembersActionsProps> = ({
168212
<LucideIcon.Tag className="mr-2 size-4" />
169213
Remove label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
170214
</DropdownMenuItem>
171-
<DropdownMenuItem onClick={() => setShowUnsubscribeModal(true)}>
215+
<DropdownMenuItem
216+
disabled={isLoadingNewsletters}
217+
onClick={() => setShowUnsubscribeModal(true)}
218+
>
172219
<LucideIcon.MailX className="mr-2 size-4" />
173220
Unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
174221
</DropdownMenuItem>
@@ -208,8 +255,9 @@ const MembersActions: React.FC<MembersActionsProps> = ({
208255
onOpenChange={setShowRemoveLabelModal}
209256
/>
210257
<UnsubscribeModal
211-
isLoading={isBulkEditing}
258+
isLoading={isBulkEditing || isUnsubscribing}
212259
memberCount={memberCount}
260+
newsletters={activeNewsletters}
213261
open={showUnsubscribeModal}
214262
onConfirm={handleUnsubscribe}
215263
onOpenChange={setShowUnsubscribeModal}

0 commit comments

Comments
 (0)