From f1cf6de2d6ab621c6254125a0900b639759d0f12 Mon Sep 17 00:00:00 2001 From: Weyland Swart <49831538+weylandswart@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:38:04 +0000 Subject: [PATCH 01/13] Ported members list from Ember to React (#26334) ref https://linear.app/ghost/issue/BER-3333/port-members-list-from-ember-to-react Ported our Members list from Ember to React. Currently on a different path and behind the membersForward flag. Includes filtering updates made on the Comments page. --- apps/admin-x-framework/src/api/members.ts | 182 +++++- apps/admin-x-framework/src/api/pages.ts | 22 + apps/admin-x-framework/src/utils/helpers.ts | 29 + .../test/unit/utils/helpers.test.ts | 164 +++++- .../advanced/labs/private-features.tsx | 4 + apps/admin/src/routes.tsx | 2 + apps/posts/src/components/member-avatar.tsx | 27 + apps/posts/src/routes.tsx | 4 + .../comments/components/comment-avatar.tsx | 28 +- .../bulk-action-modals/add-label-modal.tsx | 90 +++ .../bulk-action-modals/delete-modal.tsx | 108 ++++ .../components/bulk-action-modals/index.ts | 4 + .../bulk-action-modals/remove-label-modal.tsx | 90 +++ .../bulk-action-modals/unsubscribe-modal.tsx | 52 ++ .../members/components/members-actions.tsx | 248 +++++++++ .../members/components/members-content.tsx | 12 + .../members/components/members-filters.tsx | 104 ++++ .../members/components/members-header.tsx | 25 + .../members/components/members-layout.tsx | 17 + .../members/components/members-list-item.tsx | 146 +++++ .../views/members/components/members-list.tsx | 121 ++++ .../hooks/use-members-filter-config.tsx | 520 ++++++++++++++++++ .../members/hooks/use-members-filter-state.ts | 372 +++++++++++++ .../members/hooks/use-resource-search.ts | 90 +++ apps/posts/src/views/members/members.tsx | 163 ++++++ apps/shade/src/components/ui/filters.tsx | 23 +- ghost/core/core/shared/labs.js | 3 +- .../admin/__snapshots__/config.test.js.snap | 1 + 28 files changed, 2609 insertions(+), 42 deletions(-) create mode 100644 apps/admin-x-framework/src/api/pages.ts create mode 100644 apps/posts/src/components/member-avatar.tsx create mode 100644 apps/posts/src/views/members/components/bulk-action-modals/add-label-modal.tsx create mode 100644 apps/posts/src/views/members/components/bulk-action-modals/delete-modal.tsx create mode 100644 apps/posts/src/views/members/components/bulk-action-modals/index.ts create mode 100644 apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx create mode 100644 apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx create mode 100644 apps/posts/src/views/members/components/members-actions.tsx create mode 100644 apps/posts/src/views/members/components/members-content.tsx create mode 100644 apps/posts/src/views/members/components/members-filters.tsx create mode 100644 apps/posts/src/views/members/components/members-header.tsx create mode 100644 apps/posts/src/views/members/components/members-layout.tsx create mode 100644 apps/posts/src/views/members/components/members-list-item.tsx create mode 100644 apps/posts/src/views/members/components/members-list.tsx create mode 100644 apps/posts/src/views/members/hooks/use-members-filter-config.tsx create mode 100644 apps/posts/src/views/members/hooks/use-members-filter-state.ts create mode 100644 apps/posts/src/views/members/hooks/use-resource-search.ts create mode 100644 apps/posts/src/views/members/members.tsx diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index 4eeee70014b..d824ad7cbfc 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -1,4 +1,62 @@ -import {Meta, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks'; +import {InfiniteData} from '@tanstack/react-query'; +import {Meta, createInfiniteQuery, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks'; + +export type MemberLabel = { + id: string; + name: string; + slug: string; + created_at: string; +}; + +export type MemberTier = { + id: string; + name: string; + slug: string; + active: boolean; + type: string; +}; + +export type MemberNewsletter = { + id: string; + uuid: string; + name: string; + slug: string; + status: string; +}; + +export type MemberSubscription = { + id: string; + customer: { + id: string; + name: string | null; + email: string; + }; + plan: { + id: string; + nickname: string; + interval: 'month' | 'year'; + currency: string; + amount: number; + }; + status: string; + start_date: string; + current_period_end: string; + cancel_at_period_end: boolean; + price: { + id: string; + price_id: string; + nickname: string; + amount: number; + currency: string; + type: string; + interval: 'month' | 'year'; + }; + tier: MemberTier; + offer: { + id: string; + name: string; + } | null; +}; export type Member = { id: string; @@ -7,6 +65,26 @@ export type Member = { name?: string; email?: string; avatar_image?: string; + status: 'free' | 'paid' | 'comped'; + note?: string; + subscribed: boolean; + labels?: MemberLabel[]; + tiers?: MemberTier[]; + newsletters?: MemberNewsletter[]; + subscriptions?: MemberSubscription[]; + email_count?: number; + email_opened_count?: number; + email_open_rate?: number | null; + // TODO: The server returns geolocation as a JSON-encoded string (not a parsed object). + // Long term we should parse this on the server side and return a proper object. + geolocation?: string | null; + email_suppression?: { + suppressed: boolean; + info?: { + reason: string; + timestamp: string; + }; + }; last_seen_at: string | null; last_commented_at: string | null; can_comment?: boolean; @@ -62,3 +140,105 @@ export const useEnableMemberCommenting = createMutation< dataType: 'CommentsResponseType' } }); + +// Infinite query for members list with virtual scrolling +export interface MembersInfiniteResponseType extends MembersResponseType { + isEnd: boolean; +} + +export const useBrowseMembersInfinite = createInfiniteQuery({ + dataType, + path: '/members/', + defaultSearchParams: { + include: 'labels,tiers', + limit: '50', + order: 'created_at desc' + }, + defaultNextPageParams: (lastPage, otherParams) => { + if (!lastPage.meta?.pagination.next) { + return undefined; + } + return { + ...otherParams, + page: lastPage.meta.pagination.next.toString() + }; + }, + returnData: (originalData) => { + const {pages} = originalData as InfiniteData; + const members = pages.flatMap(page => page.members); + const meta = pages[pages.length - 1].meta; + + return { + members, + meta, + isEnd: meta ? meta.pagination.pages === meta.pagination.page : true + }; + } +}); + +// Bulk operations +export interface BulkEditAction { + type: 'addLabel' | 'removeLabel' | 'unsubscribe'; + meta?: { + label?: {id: string}; + }; +} + +export interface BulkOperationResponseType { + meta: { + stats: { + successful: number; + unsuccessful: number; + }; + unsuccessfulIds?: string[]; + errors?: Array<{id?: string; message: string}>; + }; +} + +export const useBulkEditMembers = createMutation< + BulkOperationResponseType, + {filter: string; all?: boolean; action: BulkEditAction} +>({ + method: 'PUT', + path: () => '/members/bulk/', + body: ({action}) => ({ + bulk: { + action: action.type, + meta: action.meta || {} + } + }), + searchParams: ({filter, all}) => { + if (!all && !filter) { + throw new Error('Bulk edit requires either a filter or all flag'); + } + const params: Record = {}; + if (all) { + params.all = 'true'; + } else { + params.filter = filter; + } + return params; + }, + invalidateQueries: {dataType} +}); + +export const useBulkDeleteMembers = createMutation< + BulkOperationResponseType, + {filter: string; all?: boolean} +>({ + method: 'DELETE', + path: () => '/members/', + searchParams: ({filter, all}) => { + if (!all && !filter) { + throw new Error('Bulk delete requires either a filter or all flag'); + } + const params: Record = {}; + if (all) { + params.all = 'true'; + } else { + params.filter = filter; + } + return params; + }, + invalidateQueries: {dataType} +}); diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts new file mode 100644 index 00000000000..8fce99b1e46 --- /dev/null +++ b/apps/admin-x-framework/src/api/pages.ts @@ -0,0 +1,22 @@ +import {Meta, createQuery} from '../utils/api/hooks'; + +export type Page = { + id: string; + title: string; + slug: string; + url: string; + status?: string; + published_at?: string; +}; + +export interface PagesResponseType { + meta?: Meta + pages: Page[]; +} + +const dataType = 'PagesResponseType'; + +export const useBrowsePages = createQuery({ + dataType, + path: '/pages/' +}); diff --git a/apps/admin-x-framework/src/utils/helpers.ts b/apps/admin-x-framework/src/utils/helpers.ts index 1207ff12c61..25a84dd8454 100644 --- a/apps/admin-x-framework/src/utils/helpers.ts +++ b/apps/admin-x-framework/src/utils/helpers.ts @@ -32,3 +32,32 @@ export function downloadFile(url: string) { export function downloadFromEndpoint(path: string) { downloadFile(`${getGhostPaths().apiRoot}${path}`); } + +/** + * Downloads a file by fetching it as a blob and triggering a browser download. + * Use this instead of downloadFile/downloadFromEndpoint for streaming responses + * (e.g. large CSV exports) where the iframe approach may not work reliably. + */ +export async function blobDownload(url: string, filename: string): Promise { + const response = await fetch(url, {method: 'GET'}); + + if (!response.ok) { + throw new Error(`Download failed: ${response.status} ${response.statusText}`); + } + + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + + a.href = blobUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(blobUrl); +} + +export async function blobDownloadFromEndpoint(path: string, filename: string): Promise { + const url = `${getGhostPaths().apiRoot}${path}`; + return blobDownload(url, filename); +} diff --git a/apps/admin-x-framework/test/unit/utils/helpers.test.ts b/apps/admin-x-framework/test/unit/utils/helpers.test.ts index 705b61a47bd..eef949cb333 100644 --- a/apps/admin-x-framework/test/unit/utils/helpers.test.ts +++ b/apps/admin-x-framework/test/unit/utils/helpers.test.ts @@ -1,4 +1,5 @@ -import {getGhostPaths, downloadFile, downloadFromEndpoint} from '../../../src/utils/helpers'; +import {vi} from 'vitest'; +import {getGhostPaths, downloadFile, downloadFromEndpoint, blobDownload, blobDownloadFromEndpoint} from '../../../src/utils/helpers'; describe('helpers utils', () => { // Store original values @@ -135,4 +136,163 @@ describe('helpers utils', () => { expect(() => downloadFromEndpoint('')).not.toThrow(); }); }); -}); \ No newline at end of file + + describe('blobDownload', () => { + let originalFetch: typeof global.fetch; + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + let appendChildSpy: ReturnType; + let removeElementSpy: ReturnType; + let clickSpy: ReturnType; + + beforeEach(() => { + originalFetch = global.fetch; + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + + clickSpy = vi.fn(); + removeElementSpy = vi.fn(); + appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation(node => node); + + vi.spyOn(document, 'createElement').mockImplementation(() => { + return { + href: '', + download: '', + click: clickSpy, + remove: removeElementSpy + } as unknown as HTMLElement; + }); + + URL.createObjectURL = vi.fn().mockReturnValue('blob:http://localhost/fake-blob-url'); + URL.revokeObjectURL = vi.fn(); + }); + + afterEach(() => { + global.fetch = originalFetch; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + appendChildSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it('fetches the URL and triggers a download', async () => { + const mockBlob = new Blob(['test,data'], {type: 'text/csv'}); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob) + }); + + await blobDownload('https://example.com/export.csv', 'members.csv'); + + expect(global.fetch).toHaveBeenCalledWith('https://example.com/export.csv', {method: 'GET'}); + expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob); + expect(clickSpy).toHaveBeenCalled(); + expect(removeElementSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:http://localhost/fake-blob-url'); + }); + + it('sets the correct filename on the download link', async () => { + const mockBlob = new Blob(['test'], {type: 'text/csv'}); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob) + }); + + let capturedElement: any; + vi.spyOn(document, 'createElement').mockImplementation(() => { + capturedElement = { + href: '', + download: '', + click: vi.fn(), + remove: vi.fn() + }; + return capturedElement as unknown as HTMLElement; + }); + + await blobDownload('https://example.com/export.csv', 'members.2026-02-17.csv'); + + expect(capturedElement.download).toBe('members.2026-02-17.csv'); + }); + + it('throws on non-ok response', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error' + }); + + await expect(blobDownload('https://example.com/fail', 'test.csv')) + .rejects.toThrow('Download failed: 500 Internal Server Error'); + }); + + it('propagates fetch network errors', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + await expect(blobDownload('https://example.com/fail', 'test.csv')) + .rejects.toThrow('Network error'); + }); + }); + + describe('blobDownloadFromEndpoint', () => { + let originalFetch: typeof global.fetch; + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + originalFetch = global.fetch; + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + window.location.pathname = '/ghost/settings/'; + + URL.createObjectURL = vi.fn().mockReturnValue('blob:fake'); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(document.body, 'appendChild').mockImplementation(node => node); + vi.spyOn(document, 'createElement').mockImplementation(() => { + return { + href: '', + download: '', + click: vi.fn(), + remove: vi.fn() + } as unknown as HTMLElement; + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + vi.restoreAllMocks(); + }); + + it('constructs the full URL from apiRoot and path', async () => { + const mockBlob = new Blob(['data'], {type: 'text/csv'}); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob) + }); + + await blobDownloadFromEndpoint('/members/upload/?limit=all', 'members.csv'); + + expect(global.fetch).toHaveBeenCalledWith( + '/ghost/api/admin/members/upload/?limit=all', + {method: 'GET'} + ); + }); + + it('includes subdirectory in the URL', async () => { + window.location.pathname = '/blog/ghost/settings/'; + const mockBlob = new Blob(['data'], {type: 'text/csv'}); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + blob: () => Promise.resolve(mockBlob) + }); + + await blobDownloadFromEndpoint('/members/upload/?limit=all', 'members.csv'); + + expect(global.fetch).toHaveBeenCalledWith( + '/blog/ghost/api/admin/members/upload/?limit=all', + {method: 'GET'} + ); + }); + }); +}); diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index c8a0dd5c1a2..3279ac2b95d 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -63,6 +63,10 @@ const features: Feature[] = [{ title: 'Welcome Email Editor', description: 'Enable the new welcome email editor experience', flag: 'welcomeEmailEditor' +}, { + title: 'Members Forward', + description: 'Use the new React-based members list instead of the Ember implementation', + flag: 'membersForward' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 1b76518b152..f4e9e7d309b 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -15,6 +15,7 @@ import { routes as statsRoutes } from "@tryghost/stats/src/routes"; import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; + export const routes: RouteObject[] = [ { // ForceUpgradeGuard wraps all routes to redirect to /pro when in force upgrade mode. @@ -37,6 +38,7 @@ export const routes: RouteObject[] = [ ), + // Filter out catch-all routes children: postRoutes[0].children!.filter((route) => route.path !== "*"), }, { diff --git a/apps/posts/src/components/member-avatar.tsx b/apps/posts/src/components/member-avatar.tsx new file mode 100644 index 00000000000..aecfa39d5dd --- /dev/null +++ b/apps/posts/src/components/member-avatar.tsx @@ -0,0 +1,27 @@ +import {LucideIcon, cn} from '@tryghost/shade'; + +interface MemberAvatarProps { + avatarImage?: string | null; + memberId?: string | null; + isHidden?: boolean; + className?: string; +} + +export function MemberAvatar({avatarImage, memberId, isHidden, className}: MemberAvatarProps) { + return ( +
+ {memberId && avatarImage && ( +
+ Member avatar +
+ )} +
+ +
+
+ ); +} diff --git a/apps/posts/src/routes.tsx b/apps/posts/src/routes.tsx index 5e23f77a5e4..42739ede30b 100644 --- a/apps/posts/src/routes.tsx +++ b/apps/posts/src/routes.tsx @@ -69,6 +69,10 @@ export const routes: RouteObject[] = [ path: 'comments', lazy: lazyComponent(() => import('@views/comments/comments')) }, + { + path: 'members-forward', + lazy: lazyComponent(() => import('@views/members/members')) + }, // Error handling { diff --git a/apps/posts/src/views/comments/components/comment-avatar.tsx b/apps/posts/src/views/comments/components/comment-avatar.tsx index 00022908f9d..87c88f3f876 100644 --- a/apps/posts/src/views/comments/components/comment-avatar.tsx +++ b/apps/posts/src/views/comments/components/comment-avatar.tsx @@ -1,27 +1 @@ -import {LucideIcon, cn} from '@tryghost/shade'; - -interface CommentAvatarProps { - avatarImage?: string | null; - memberId?: string | null; - isHidden?: boolean; - className?: string; -} - -export function CommentAvatar({avatarImage, memberId, isHidden, className}: CommentAvatarProps) { - return ( -
- {memberId && avatarImage && ( -
- Member avatar -
- )} -
- -
-
- ); -} +export {MemberAvatar as CommentAvatar} from '@components/member-avatar'; diff --git a/apps/posts/src/views/members/components/bulk-action-modals/add-label-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/add-label-modal.tsx new file mode 100644 index 00000000000..7bb53a97d57 --- /dev/null +++ b/apps/posts/src/views/members/components/bulk-action-modals/add-label-modal.tsx @@ -0,0 +1,90 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@tryghost/shade'; +import {Label} from '@tryghost/admin-x-framework/api/labels'; +import {useState} from 'react'; + +interface AddLabelModalProps { + open: boolean; + labels: Label[]; + memberCount: number; + onOpenChange: (open: boolean) => void; + onConfirm: (labelId: string) => void; + isLoading?: boolean; +} + +export function AddLabelModal({ + open, + labels, + memberCount, + onOpenChange, + onConfirm, + isLoading = false +}: AddLabelModalProps) { + const [selectedLabelId, setSelectedLabelId] = useState(''); + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setSelectedLabelId(''); + } + onOpenChange(isOpen); + }; + + const handleConfirm = () => { + if (selectedLabelId) { + onConfirm(selectedLabelId); + } + }; + + return ( + + + + Add label to members + + Add a label to {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}. + + + +
+ + +
+ + + + + +
+
+ ); +} diff --git a/apps/posts/src/views/members/components/bulk-action-modals/delete-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/delete-modal.tsx new file mode 100644 index 00000000000..eeb4001d233 --- /dev/null +++ b/apps/posts/src/views/members/components/bulk-action-modals/delete-modal.tsx @@ -0,0 +1,108 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + LucideIcon +} from '@tryghost/shade'; +import {useState} from 'react'; + +interface DeleteModalProps { + open: boolean; + memberCount: number; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + onExportBackup: () => void; + isLoading?: boolean; +} + +export function DeleteModal({ + open, + memberCount, + onOpenChange, + onConfirm, + onExportBackup, + isLoading = false +}: DeleteModalProps) { + const [backupExported, setBackupExported] = useState(false); + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setBackupExported(false); + } + onOpenChange(isOpen); + }; + + const handleExportBackup = () => { + onExportBackup(); + setBackupExported(true); + }; + + const handleConfirm = () => { + onConfirm(); + }; + + return ( + + + + Delete members + + Are you sure you want to delete {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}? + This action cannot be undone. + + + +
+
+ +
+

+ Export a backup before deleting +

+

+ We recommend exporting a backup of these members before deleting them. + You can use this backup to restore them if needed. +

+ +
+
+
+ + + + + +
+
+ ); +} diff --git a/apps/posts/src/views/members/components/bulk-action-modals/index.ts b/apps/posts/src/views/members/components/bulk-action-modals/index.ts new file mode 100644 index 00000000000..424678a758f --- /dev/null +++ b/apps/posts/src/views/members/components/bulk-action-modals/index.ts @@ -0,0 +1,4 @@ +export {AddLabelModal} from './add-label-modal'; +export {RemoveLabelModal} from './remove-label-modal'; +export {UnsubscribeModal} from './unsubscribe-modal'; +export {DeleteModal} from './delete-modal'; diff --git a/apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx new file mode 100644 index 00000000000..404d68860d5 --- /dev/null +++ b/apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx @@ -0,0 +1,90 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@tryghost/shade'; +import {Label} from '@tryghost/admin-x-framework/api/labels'; +import {useState} from 'react'; + +interface RemoveLabelModalProps { + open: boolean; + labels: Label[]; + memberCount: number; + onOpenChange: (open: boolean) => void; + onConfirm: (labelId: string) => void; + isLoading?: boolean; +} + +export function RemoveLabelModal({ + open, + labels, + memberCount, + onOpenChange, + onConfirm, + isLoading = false +}: RemoveLabelModalProps) { + const [selectedLabelId, setSelectedLabelId] = useState(''); + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + setSelectedLabelId(''); + } + onOpenChange(isOpen); + }; + + const handleConfirm = () => { + if (selectedLabelId) { + onConfirm(selectedLabelId); + } + }; + + return ( + + + + Remove label from members + + Remove a label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}. + + + +
+ + +
+ + + + + +
+
+ ); +} diff --git a/apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx new file mode 100644 index 00000000000..aa6ab31158a --- /dev/null +++ b/apps/posts/src/views/members/components/bulk-action-modals/unsubscribe-modal.tsx @@ -0,0 +1,52 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@tryghost/shade'; + +interface UnsubscribeModalProps { + open: boolean; + memberCount: number; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; +} + +export function UnsubscribeModal({ + open, + memberCount, + onOpenChange, + onConfirm, + isLoading = false +}: UnsubscribeModalProps) { + return ( + + + + Unsubscribe members + + Are you sure you want to unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'} from all newsletters? + They will no longer receive any email newsletters from you. + + + + + + + + + + ); +} diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx new file mode 100644 index 00000000000..5fc5017aba5 --- /dev/null +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -0,0 +1,248 @@ +import React, {useCallback, useState} from 'react'; +import {AddLabelModal, DeleteModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + LucideIcon +} from '@tryghost/shade'; +import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; +import {toast} from 'sonner'; +import {useBrowseLabels} from '@tryghost/admin-x-framework/api/labels'; +import {useBulkDeleteMembers, useBulkEditMembers} from '@tryghost/admin-x-framework/api/members'; + +interface MembersActionsProps { + isFiltered: boolean; + memberCount: number; + nql?: string; + canBulkDelete: boolean; +} + +async function exportMembers(filter?: string): Promise { + const params = new URLSearchParams({limit: 'all'}); + if (filter) { + params.set('filter', filter); + } + const datetime = new Date().toJSON().substring(0, 10); + await blobDownloadFromEndpoint(`/members/upload/?${params}`, `members.${datetime}.csv`); +} + +const MembersActions: React.FC = ({ + isFiltered, + memberCount, + nql, + canBulkDelete +}) => { + const {data: labelsData} = useBrowseLabels({}); + const labels = labelsData?.labels || []; + + const {mutate: bulkEdit, isLoading: isBulkEditing} = useBulkEditMembers(); + const {mutate: bulkDelete, isLoading: isBulkDeleting} = useBulkDeleteMembers(); + + const [showAddLabelModal, setShowAddLabelModal] = useState(false); + const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); + const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleExport = useCallback(async () => { + try { + await exportMembers(nql); + } catch (e) { + toast.error('Export failed', { + description: 'There was a problem downloading your member data. Please check your connection and try again.', + duration: 8000 + }); + throw e; + } + }, [nql]); + + const handleAddLabel = useCallback((labelId: string) => { + bulkEdit({ + filter: nql || '', + all: !nql, + action: { + type: 'addLabel', + meta: {label: {id: labelId}} + } + }, { + onSuccess: () => { + setShowAddLabelModal(false); + toast.success('Label added successfully'); + }, + onError: () => { + toast.error('Failed to add label', { + description: 'There was a problem applying this label. Please try again.', + duration: 8000 + }); + } + }); + }, [bulkEdit, nql]); + + const handleRemoveLabel = useCallback((labelId: string) => { + bulkEdit({ + filter: nql || '', + all: !nql, + action: { + type: 'removeLabel', + meta: {label: {id: labelId}} + } + }, { + onSuccess: () => { + setShowRemoveLabelModal(false); + toast.success('Label removed successfully'); + }, + onError: () => { + toast.error('Failed to remove label', { + description: 'There was a problem removing this label. Please try again.', + duration: 8000 + }); + } + }); + }, [bulkEdit, nql]); + + const handleUnsubscribe = useCallback(() => { + bulkEdit({ + filter: nql || '', + all: !nql, + action: { + type: 'unsubscribe' + } + }, { + onSuccess: () => { + setShowUnsubscribeModal(false); + toast.success('Members unsubscribed successfully'); + }, + onError: () => { + toast.error('Failed to unsubscribe members', { + description: 'There was a problem unsubscribing these members. Please try again.', + duration: 8000 + }); + } + }); + }, [bulkEdit, nql]); + + const handleDelete = useCallback(() => { + bulkDelete({ + filter: nql || '', + all: !nql + }, { + onSuccess: () => { + setShowDeleteModal(false); + toast.success('Members deleted successfully'); + }, + onError: () => { + toast.error('Failed to delete members', { + description: 'There was a problem deleting these members. Please try again.', + duration: 8000 + }); + } + }); + }, [bulkDelete, nql]); + + const handleExportBackup = useCallback(async () => { + try { + await exportMembers(nql); + } catch (e) { + toast.error('Export failed', { + description: 'There was a problem downloading your backup. Please check your connection and try again.', + duration: 8000 + }); + throw e; + } + }, [nql]); + + return ( + <> + {/* Actions Dropdown */} + + + + + + {/* Export */} + + + {isFiltered + ? `Export ${memberCount.toLocaleString()} members` + : 'Export all members'} + + + {/* Bulk actions only when NQL filter is present (not just search) */} + {nql && ( + <> + + setShowAddLabelModal(true)}> + + Add label to {memberCount.toLocaleString()} members + + setShowRemoveLabelModal(true)}> + + Remove label from {memberCount.toLocaleString()} members + + setShowUnsubscribeModal(true)}> + + Unsubscribe {memberCount.toLocaleString()} members + + + setShowDeleteModal(true)} + > + + Delete {memberCount.toLocaleString()} members + + + )} + + + + {/* New Member Button - styled like Tags */} + + + {/* Modals */} + + + + + + ); +}; + +export default MembersActions; diff --git a/apps/posts/src/views/members/components/members-content.tsx b/apps/posts/src/views/members/components/members-content.tsx new file mode 100644 index 00000000000..99b9f661975 --- /dev/null +++ b/apps/posts/src/views/members/components/members-content.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import {cn} from '@tryghost/shade'; + +const MembersContent: React.FC> = ({children, className, ...props}) => { + return ( +
+ {children} +
+ ); +}; + +export default MembersContent; diff --git a/apps/posts/src/views/members/components/members-filters.tsx b/apps/posts/src/views/members/components/members-filters.tsx new file mode 100644 index 00000000000..4d6d9751f89 --- /dev/null +++ b/apps/posts/src/views/members/components/members-filters.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import {Filter, Filters, LucideIcon} from '@tryghost/shade'; +import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {getSiteTimezone} from '@src/utils/get-site-timezone'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; +import {useBrowseLabels} from '@tryghost/admin-x-framework/api/labels'; +import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; +import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; +import {useMembersFilterConfig} from '../hooks/use-members-filter-config'; +import {useResourceSearch} from '../hooks/use-resource-search'; + +interface MembersFiltersProps { + filters: Filter[]; + onFiltersChange: (filters: Filter[]) => void; +} + +const MembersFilters: React.FC = ({ + filters, + onFiltersChange +}) => { + // Fetch required data for filters + const {data: labelsData} = useBrowseLabels({searchParams: {limit: '100'}}); + const {data: tiersData} = useBrowseTiers({searchParams: {limit: '100'}}); + const {data: newslettersData} = useBrowseNewsletters({searchParams: {limit: '100'}}); + const {data: settingsData} = useBrowseSettings({}); + const {data: configData} = useBrowseConfig({}); + + // Get settings + const settings = settingsData?.settings || []; + const paidMembersEnabled = getSettingValue(settings, 'paid_members_enabled') === true; + const emailAnalyticsEnabled = configData?.config?.emailAnalytics === true; + const membersTrackSources = getSettingValue(settings, 'members_track_sources') === true; + const emailTrackOpens = getSettingValue(settings, 'email_track_opens') === true; + const emailTrackClicks = getSettingValue(settings, 'email_track_clicks') === true; + const audienceFeedbackEnabled = configData?.config?.labs?.audienceFeedback === true; + const siteTimezone = getSiteTimezone(settings); + + // Get data + const labels = labelsData?.labels || []; + const tiers = tiersData?.tiers || []; + const newsletters = newslettersData?.newsletters || []; + const activePaidTiers = tiers.filter(t => t.type === 'paid' && t.active); + const hasMultipleTiers = activePaidTiers.length > 1; + + // Resource search hooks for post/page and email pickers + const postSearch = useResourceSearch('post'); + const emailSearch = useResourceSearch('email'); + + // Get filter configuration + const filterFields = useMembersFilterConfig({ + labels, + tiers: activePaidTiers, + newsletters: newsletters.filter(n => n.status === 'active'), + hasMultipleTiers, + paidMembersEnabled, + emailAnalyticsEnabled, + labelsOptions: labels.map(l => ({value: l.slug, label: l.name})), + tiersOptions: activePaidTiers.map(t => ({value: t.id, label: t.name})), + postResourceOptions: postSearch.options, + onPostResourceSearchChange: postSearch.onSearchChange, + postResourceSearchValue: postSearch.searchValue, + postResourceLoading: postSearch.isLoading, + emailResourceOptions: emailSearch.options, + onEmailResourceSearchChange: emailSearch.onSearchChange, + emailResourceSearchValue: emailSearch.searchValue, + emailResourceLoading: emailSearch.isLoading, + membersTrackSources, + emailTrackOpens, + emailTrackClicks, + audienceFeedbackEnabled, + siteTimezone + }); + + const hasFilters = filters.length > 0; + + return ( + + ) : ( + + ) + } + addButtonText={hasFilters ? 'Add filter' : 'Filter'} + allowMultiple={true} + className={`[&>button]:order-last ${ + hasFilters ? '[&>button]:border-none' : 'w-auto' + }`} + clearButtonClassName="font-normal text-muted-foreground" + clearButtonIcon={} + clearButtonText="Clear" + fields={filterFields} + filters={filters} + keyboardShortcut="f" + popoverAlign={hasFilters ? 'start' : 'end'} + showClearButton={hasFilters} + showSearchInput={false} + onChange={onFiltersChange} + /> + ); +}; + +export default MembersFilters; diff --git a/apps/posts/src/views/members/components/members-header.tsx b/apps/posts/src/views/members/components/members-header.tsx new file mode 100644 index 00000000000..c404decaf9c --- /dev/null +++ b/apps/posts/src/views/members/components/members-header.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import {Header} from '@tryghost/shade'; + +interface MembersHeaderProps { + children?: React.ReactNode; + totalMembers: number; + isLoading: boolean; +} + +const MembersHeader: React.FC = ({ + children, + totalMembers, + isLoading +}) => { + return ( +
+ + Members {!isLoading && {totalMembers.toLocaleString()}} + + {children} +
+ ); +}; + +export default MembersHeader; diff --git a/apps/posts/src/views/members/components/members-layout.tsx b/apps/posts/src/views/members/components/members-layout.tsx new file mode 100644 index 00000000000..ad1755432d0 --- /dev/null +++ b/apps/posts/src/views/members/components/members-layout.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const MembersLayout: React.FC<{children: React.ReactNode}> = ({children}) => { + return ( +
+
+
+
+ {children} +
+
+
+
+ ); +}; + +export default MembersLayout; diff --git a/apps/posts/src/views/members/components/members-list-item.tsx b/apps/posts/src/views/members/components/members-list-item.tsx new file mode 100644 index 00000000000..76ada458ad8 --- /dev/null +++ b/apps/posts/src/views/members/components/members-list-item.tsx @@ -0,0 +1,146 @@ +import moment from 'moment-timezone'; + +import {Member} from '@tryghost/admin-x-framework/api/members'; +import {MemberAvatar} from '@components/member-avatar'; + +// --- Helpers --- + +function formatLocation(geolocation: Member['geolocation']): string { + if (!geolocation) { + return 'Unknown'; + } + + try { + const parsed = JSON.parse(geolocation) as {country?: string; region?: string; country_code?: string}; + + if (!parsed.country) { + return 'Unknown'; + } + + // For US, show "State, US" + if (parsed.country_code === 'US' && parsed.region) { + return `${parsed.region}, US`; + } + + return parsed.country; + } catch { + return 'Unknown'; + } +} + +function getStatusLabel(status: Member['status']): string { + switch (status) { + case 'paid': + return 'Paid'; + case 'comped': + return 'Complimentary'; + default: + return 'Free'; + } +} + +// --- Sub-components --- + +function MembersListItemName({item}: {item: Member}) { + return ( +
+ +
+
+ {item.name || item.email || 'Anonymous'} +
+ {item.name && item.email && ( +
+ {item.email} +
+ )} +
+
+ ); +} + +function MembersListItemStatus({status, tiers}: {status: Member['status']; tiers?: Member['tiers']}) { + const tierNames = tiers?.map(t => t.name).join(', '); + return ( +
+
+
{getStatusLabel(status)}
+ {tierNames && ( +
+ {tierNames} +
+ )} +
+
+ ); +} + +function MembersListItemOpenRate({emailOpenRate}: {emailOpenRate: number | null | undefined}) { + return ( +
+ {emailOpenRate !== null && emailOpenRate !== undefined + ? `${Math.round(emailOpenRate)}%` + : 'N/A'} +
+ ); +} + +function MembersListItemLocation({geolocation}: {geolocation: Member['geolocation']}) { + return ( +
+ {formatLocation(geolocation)} +
+ ); +} + +function MembersListItemCreated({createdAt}: {createdAt: string}) { + return ( +
+
{moment.utc(createdAt).format('D MMM YYYY')}
+
+ {moment.utc(createdAt).fromNow()} +
+
+ ); +} + +// --- Main component --- + +interface MembersListItemProps { + item: Member; + gridCols: string; + showEmailOpenRate: boolean; + onClick: (memberId: string) => void; +} + +function MembersListItem({item, gridCols, showEmailOpenRate, onClick, ...props}: MembersListItemProps & Omit, 'onClick'>) { + return ( +
onClick(item.id)} + > + + + {showEmailOpenRate && ( + + )} + + +
+ ); +} + +export default MembersListItem; +export { + MembersListItemName, + MembersListItemStatus, + MembersListItemOpenRate, + MembersListItemLocation, + MembersListItemCreated +}; diff --git a/apps/posts/src/views/members/components/members-list.tsx b/apps/posts/src/views/members/components/members-list.tsx new file mode 100644 index 00000000000..eeed1ba9de6 --- /dev/null +++ b/apps/posts/src/views/members/components/members-list.tsx @@ -0,0 +1,121 @@ +import MembersListItem from './members-list-item'; +import {Member} from '@tryghost/admin-x-framework/api/members'; +import {forwardRef, useRef} from 'react'; +import {useInfiniteVirtualScroll} from '@components/virtual-table/use-infinite-virtual-scroll'; +import {useScrollRestoration} from '@components/virtual-table/use-scroll-restoration'; + +const SpacerRow = ({height}: {height: number}) => ( +