Skip to content

Commit 68294ab

Browse files
committed
Added multiple subscriptions member filter
no issue This adds a labs-gated warning and exact members filter for identifying members with active subscriptions across multiple Stripe customers.
1 parent efcad61 commit 68294ab

16 files changed

Lines changed: 672 additions & 31 deletions

File tree

apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ const features: Feature[] = [{
7171
title: 'Get helper deduplication',
7272
description: 'Deduplicate identical {{#get}} helper queries within a single request to avoid redundant database calls',
7373
flag: 'getHelperDeduplication'
74+
}, {
75+
title: 'Multiple subscriptions filter',
76+
description: 'Show a members notification and filter for multiple active Stripe subscriptions',
77+
flag: 'multipleSubsFilter'
7478
}];
7579

7680
const AlphaFeatures: React.FC = () => {

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React, {useCallback, useState} from 'react';
22
import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals';
33
import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components';
44
import {type ImportResponse} from './bulk-action-modals/import-members/state';
5-
import {LucideIcon} from '@tryghost/shade/utils';
5+
import {LucideIcon, formatNumber} from '@tryghost/shade/utils';
66
import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers';
77
import {buildMemberOperationParams} from '../member-query-params';
88
import {buildMembersUrl} from '../member-route';
@@ -229,25 +229,25 @@ const MembersActions: React.FC<MembersActionsProps> = ({
229229
<DropdownMenuItem onClick={handleExport}>
230230
<LucideIcon.Download className="mr-2 size-4" />
231231
{hasFilterOrSearch
232-
? `Export ${memberCount.toLocaleString()} members`
232+
? `Export ${formatNumber(memberCount)} members`
233233
: 'Export all members'}
234234
</DropdownMenuItem>
235235

236236
<DropdownMenuSeparator />
237237
<DropdownMenuItem onClick={() => setShowAddLabelModal(true)}>
238238
<LucideIcon.Tags className="mr-2 size-4" />
239-
Add label to {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
239+
Add label to {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
240240
</DropdownMenuItem>
241241
<DropdownMenuItem onClick={() => setShowRemoveLabelModal(true)}>
242242
<LucideIcon.Tag className="mr-2 size-4" />
243-
Remove label from {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
243+
Remove label from {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
244244
</DropdownMenuItem>
245245
<DropdownMenuItem
246246
disabled={isLoadingNewsletters}
247247
onClick={() => setShowUnsubscribeModal(true)}
248248
>
249249
<LucideIcon.MailX className="mr-2 size-4" />
250-
Unsubscribe {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
250+
Unsubscribe {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
251251
</DropdownMenuItem>
252252
<DropdownMenuSeparator />
253253
<DropdownMenuItem
@@ -256,7 +256,7 @@ const MembersActions: React.FC<MembersActionsProps> = ({
256256
onClick={() => setShowDeleteModal(true)}
257257
>
258258
<LucideIcon.Trash2 className="mr-2 size-4" />
259-
Delete {memberCount.toLocaleString()} {memberCount === 1 ? 'member' : 'members'}
259+
Delete {formatNumber(memberCount)} {memberCount === 1 ? 'member' : 'members'}
260260
</DropdownMenuItem>
261261
</>
262262
)}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {Banner, Button} from '@tryghost/shade/components';
2+
import {formatNumber} from '@tryghost/shade/utils';
3+
import {useMultipleActiveStripeCustomersBanner} from '../hooks/use-multiple-active-stripe-customers-banner';
4+
5+
interface MultipleActiveStripeCustomersBannerProps {
6+
hasStripeEnabled: boolean;
7+
multipleSubsFilterEnabled: boolean;
8+
nql?: string;
9+
search: string;
10+
}
11+
12+
const MultipleActiveStripeCustomersBanner = ({
13+
hasStripeEnabled,
14+
multipleSubsFilterEnabled,
15+
nql,
16+
search
17+
}: MultipleActiveStripeCustomersBannerProps) => {
18+
const banner = useMultipleActiveStripeCustomersBanner({
19+
hasStripeEnabled,
20+
multipleSubsFilterEnabled,
21+
nql,
22+
search
23+
});
24+
25+
if (!banner.shouldShow) {
26+
return null;
27+
}
28+
29+
return (
30+
<Banner
31+
role="status"
32+
variant="warning"
33+
{...(banner.canDismiss ? {
34+
dismissible: true as const,
35+
onDismiss: banner.handleDismiss
36+
} : {
37+
dismissible: false as const
38+
})}
39+
>
40+
<div className="flex flex-col gap-3 pr-8 sm:flex-row sm:items-center sm:justify-between">
41+
<div className="text-sm font-medium">
42+
{formatNumber(banner.count)} {banner.count === 1 ? 'member has' : 'members have'} more than one active paid subscription.{' '}
43+
<a className="font-semibold underline" href="https://ghost.org/help/duplicate-subscription-warning/" rel="noopener noreferrer" target="_blank">Learn more</a>
44+
</div>
45+
{banner.canDismiss && (
46+
<Button size="sm" variant="outline" onClick={banner.handleViewMembers}>
47+
View members
48+
</Button>
49+
)}
50+
</div>
51+
</Banner>
52+
);
53+
};
54+
55+
export default MultipleActiveStripeCustomersBanner;

apps/posts/src/views/members/hooks/use-members-filter-state.test.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,52 @@ describe('useMembersFilterState', () => {
5757
expect(result.current.hasFilterOrSearch).toBe(false);
5858
});
5959

60+
it('preserves the multiple active Stripe customers raw filter', async () => {
61+
const {result} = renderHook(() => {
62+
const state = useMembersFilterState('UTC', {
63+
preserveMultipleActiveStripeCustomersFilter: true
64+
});
65+
const [searchParams] = useSearchParams();
66+
67+
return {
68+
...state,
69+
query: searchParams.toString()
70+
};
71+
}, {
72+
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1')
73+
});
74+
75+
await waitFor(() => {
76+
expect(result.current.nql).toBe('count.active_stripe_customers:>1');
77+
});
78+
79+
expect(result.current.filters).toEqual([]);
80+
expect(result.current.query).toBe('filter=count.active_stripe_customers%3A%3E1');
81+
expect(result.current.hasFilterOrSearch).toBe(true);
82+
});
83+
84+
it('drops the multiple active Stripe customers raw filter when preservation is disabled', async () => {
85+
const {result} = renderHook(() => {
86+
const state = useMembersFilterState('UTC');
87+
const [searchParams] = useSearchParams();
88+
89+
return {
90+
...state,
91+
query: searchParams.toString()
92+
};
93+
}, {
94+
wrapper: createWrapper('/?filter=count.active_stripe_customers%3A%3E1')
95+
});
96+
97+
await waitFor(() => {
98+
expect(result.current.query).toBe('');
99+
});
100+
101+
expect(result.current.filters).toEqual([]);
102+
expect(result.current.nql).toBeUndefined();
103+
expect(result.current.hasFilterOrSearch).toBe(false);
104+
});
105+
60106
it('retains supported filters and rewrites mixed URLs canonically', async () => {
61107
const {result} = renderHook(() => {
62108
const state = useMembersFilterState('UTC');

apps/posts/src/views/members/hooks/use-members-filter-state.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {Filter} from '@tryghost/shade/patterns';
22
import {getMemberFields} from '../member-fields';
33
import {hasTimezoneSensitiveMemberFilter, isPredicateEnabled, parseMemberFilter, serializeMemberFilters} from '../member-filter-query';
4+
import {isMultipleActiveStripeCustomersFilter} from '../multiple-active-stripe-customers';
45
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
56
import {useSearchParams} from 'react-router';
67
import type {MemberFields} from '../member-fields';
@@ -26,6 +27,11 @@ interface ToSearchParamsOptions {
2627
search: string;
2728
timezone: string;
2829
fields: MemberFields;
30+
rawFilter?: string;
31+
}
32+
33+
interface UseMembersFilterStateOptions {
34+
preserveMultipleActiveStripeCustomersFilter?: boolean;
2935
}
3036

3137
/**
@@ -43,10 +49,13 @@ export function shouldDelayMembersDateFilterHydration(
4349
return Boolean(filterParam) && isLoadingDependencies && !hasResolvedDependencies && hasTimezoneSensitiveMemberFilter(filterParam);
4450
}
4551

46-
function toSearchParams({baseSearchParams, filters, search, timezone, fields}: ToSearchParamsOptions): URLSearchParams {
52+
function getEnabledFilters(filters: Filter[], fields: MemberFields): Filter[] {
53+
return filters.filter(predicate => isPredicateEnabled(predicate, fields));
54+
}
55+
56+
function toSearchParams({baseSearchParams, filters, search, timezone, fields, rawFilter}: ToSearchParamsOptions): URLSearchParams {
4757
const params = new URLSearchParams(baseSearchParams);
48-
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
49-
const filter = serializeMemberFilters(enabled, timezone);
58+
const filter = rawFilter || serializeMemberFilters(getEnabledFilters(filters, fields), timezone);
5059

5160
params.delete('filter');
5261
params.delete('search');
@@ -62,26 +71,35 @@ function toSearchParams({baseSearchParams, filters, search, timezone, fields}: T
6271
return params;
6372
}
6473

65-
export function useMembersFilterState(timezone: string): UseMembersFilterStateReturn {
74+
export function useMembersFilterState(timezone: string, hookOptions: UseMembersFilterStateOptions = {}): UseMembersFilterStateReturn {
6675
const fields = useMemo(() => getMemberFields(), []);
6776
const [searchParams, setSearchParams] = useSearchParams();
6877
const lastWrittenQueryRef = useRef<string | null>(null);
6978
const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]);
7079
const currentQuery = useMemo(() => searchParams.toString(), [searchParams]);
80+
const preserveMultipleActiveStripeCustomersFilter = hookOptions.preserveMultipleActiveStripeCustomersFilter === true;
7181

7282
const parsedFilters = useMemo(() => {
73-
return parseMemberFilter(filterParam, timezone).filter(predicate => isPredicateEnabled(predicate, fields));
83+
return getEnabledFilters(parseMemberFilter(filterParam, timezone), fields);
7484
}, [filterParam, timezone, fields]);
7585
const [filters, setDraftFilters] = useState<Filter[]>(parsedFilters);
86+
const preservedRawFilter = useMemo(() => {
87+
return preserveMultipleActiveStripeCustomersFilter && filters.length === 0 && isMultipleActiveStripeCustomersFilter(filterParam)
88+
? filterParam
89+
: undefined;
90+
}, [filterParam, filters.length, preserveMultipleActiveStripeCustomersFilter]);
7691

7792
const search = useMemo(() => {
7893
return searchParams.get('search') ?? '';
7994
}, [searchParams]);
8095

8196
const nql = useMemo(() => {
82-
const enabled = filters.filter(predicate => isPredicateEnabled(predicate, fields));
83-
return serializeMemberFilters(enabled, timezone);
84-
}, [filters, timezone, fields]);
97+
if (preservedRawFilter) {
98+
return preservedRawFilter;
99+
}
100+
101+
return serializeMemberFilters(getEnabledFilters(filters, fields), timezone);
102+
}, [filters, preservedRawFilter, timezone, fields]);
85103

86104
useEffect(() => {
87105
if (currentQuery !== lastWrittenQueryRef.current) {
@@ -100,15 +118,16 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
100118
filters,
101119
search,
102120
timezone,
103-
fields
121+
fields,
122+
rawFilter: preservedRawFilter
104123
});
105124
const nextQuery = nextParams.toString();
106125

107126
if (nextQuery !== currentQuery) {
108127
lastWrittenQueryRef.current = nextQuery;
109128
setSearchParams(nextParams, {replace: true});
110129
}
111-
}, [currentQuery, filters, search, searchParams, setSearchParams, timezone, fields]);
130+
}, [currentQuery, filters, preservedRawFilter, search, searchParams, setSearchParams, timezone, fields]);
112131

113132
const setFilters = useCallback((nextFilters: Filter[], setOptions: SetFiltersOptions = {}) => {
114133
const replace = setOptions.replace ?? true;
@@ -132,12 +151,13 @@ export function useMembersFilterState(timezone: string): UseMembersFilterStateRe
132151
filters,
133152
search: nextSearch,
134153
timezone,
135-
fields
154+
fields,
155+
rawFilter: preservedRawFilter
136156
});
137157

138158
lastWrittenQueryRef.current = nextParams.toString();
139159
setSearchParams(nextParams, {replace});
140-
}, [filters, searchParams, setSearchParams, timezone, fields]);
160+
}, [filters, preservedRawFilter, searchParams, setSearchParams, timezone, fields]);
141161

142162
const clearFilters = useCallback(({replace = true}: SetFiltersOptions = {}) => {
143163
const nextParams = toSearchParams({

0 commit comments

Comments
 (0)