diff --git a/.github/workflows/cleanup-stripe-test-accounts.yml b/.github/workflows/cleanup-stripe-test-accounts.yml index df5c609a389..3b022645830 100644 --- a/.github/workflows/cleanup-stripe-test-accounts.yml +++ b/.github/workflows/cleanup-stripe-test-accounts.yml @@ -42,9 +42,16 @@ jobs: RESPONSE=$(curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100&starting_after=$STARTING_AFTER") fi - # Extract account data - ACCOUNTS=$(echo "$RESPONSE" | jq -c '.data[]') - HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more') + # Check for API errors + ERROR=$(echo "$RESPONSE" | jq -r '.error.message // empty') + if [ -n "$ERROR" ]; then + echo "Stripe API error: $ERROR" + exit 1 + fi + + # Extract account data - handle null/missing data array gracefully + ACCOUNTS=$(echo "$RESPONSE" | jq -c '.data // [] | .[]') + HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more // false') while IFS= read -r account; do [ -z "$account" ] && continue 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-x-settings/src/components/settings/membership/membership-settings.tsx b/apps/admin-x-settings/src/components/settings/membership/membership-settings.tsx index 583f40f534a..ae2c5814038 100644 --- a/apps/admin-x-settings/src/components/settings/membership/membership-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/membership-settings.tsx @@ -6,7 +6,6 @@ import SearchableSection from '../../searchable-section'; import SpamFilters from '../advanced/spam-filters'; import Tiers from './tiers'; import TipsAndDonations from '../growth/tips-and-donations'; -import useFeatureFlag from '../../../hooks/use-feature-flag'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; import {useGlobalData} from '../../providers/global-data-provider'; @@ -22,7 +21,6 @@ const MembershipSettings: React.FC = () => { const {config, settings} = useGlobalData(); const [hasTipsAndDonations] = getSettingValues(settings, ['donations_enabled']) as [boolean]; const hasStripeEnabled = checkStripeEnabled(settings || [], config || {}); - const hasWelcomeEmails = useFeatureFlag('welcomeEmails'); return ( @@ -30,7 +28,7 @@ const MembershipSettings: React.FC = () => { - {hasWelcomeEmails && } + {hasTipsAndDonations && hasStripeEnabled && } ); diff --git a/apps/admin-x-settings/src/components/sidebar.tsx b/apps/admin-x-settings/src/components/sidebar.tsx index d8fab185df2..10a16da274a 100644 --- a/apps/admin-x-settings/src/components/sidebar.tsx +++ b/apps/admin-x-settings/src/components/sidebar.tsx @@ -1,7 +1,6 @@ import GhostLogo from '../assets/images/orb-pink.png'; import React, {useEffect, useRef} from 'react'; import clsx from 'clsx'; -import useFeatureFlag from '../hooks/use-feature-flag'; import {Button, Icon, SettingNavItem, type SettingNavItemProps, SettingNavSection, TextField, useFocusContext} from '@tryghost/admin-x-design-system'; import {checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings'; @@ -109,7 +108,6 @@ const Sidebar: React.FC = () => { const {settings, config} = useGlobalData(); const [hasTipsAndDonations] = getSettingValues(settings, ['donations_enabled']) as [string]; const hasStripeEnabled = checkStripeEnabled(settings || [], config || {}); - const hasWelcomeEmails = useFeatureFlag('welcomeEmails'); const handleSectionClick = (e?: React.MouseEvent) => { if (e) { @@ -191,7 +189,7 @@ const Sidebar: React.FC = () => { - {hasWelcomeEmails && } + {hasTipsAndDonations && hasStripeEnabled && } diff --git a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts index 22ded8e1a80..84b949fab62 100644 --- a/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/member-welcome-emails.test.ts @@ -25,20 +25,10 @@ const newslettersRequest = { test.describe('Member emails settings', async () => { test.describe('Welcome email modal', async () => { test('Escape key closes test email dropdown without closing modal', async ({page}) => { - // Config with welcomeEmails feature flag enabled - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -75,20 +65,10 @@ test.describe('Member emails settings', async () => { }); test('Escape key closes modal when test email dropdown is not open', async ({page}) => { - // Config with welcomeEmails feature flag enabled - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -115,20 +95,10 @@ test.describe('Member emails settings', async () => { }); test('Welcome email modal does not start dirty but becomes dirty after edit', async ({page}) => { - // Config with welcomeEmails feature flag enabled - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -173,20 +143,10 @@ test.describe('Member emails settings', async () => { }); test('Escape key does not close modal or navigate away when pressed from Koenig link input', async ({page}) => { - // Config with welcomeEmails feature flag enabled - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture} }}); @@ -237,15 +197,6 @@ test.describe('Member emails settings', async () => { }); test('uses automated email sender fields when populated, even if newsletter differs', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const populatedAutomatedEmailsFixture = { automated_emails: [{ ...automatedEmailsFixture.automated_emails[0], @@ -267,7 +218,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: populatedAutomatedEmailsFixture}, browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} }}); @@ -288,15 +239,6 @@ test.describe('Member emails settings', async () => { }); test('falls back to default newsletter sender values when automated fields are empty', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const emptyAutomatedSenderFixture = { automated_emails: [{ ...automatedEmailsFixture.automated_emails[0], @@ -318,7 +260,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} }}); @@ -339,15 +281,6 @@ test.describe('Member emails settings', async () => { }); test('preview card uses newsletter sender name when automated sender name is empty', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const emptyAutomatedSenderFixture = { automated_emails: [{ ...automatedEmailsFixture.automated_emails[0], @@ -365,7 +298,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedSenderFixture}, browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: defaultNewsletterResponse} }}); @@ -384,16 +317,6 @@ test.describe('Member emails settings', async () => { // NY-842: Tests for editing/viewing welcome emails before activation test.describe('Email preview visibility and edit-before-activation', async () => { test('Email preview card is visible with default subject when no DB row exists', async ({page}) => { - // Config with welcomeEmails feature flag enabled - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - // Empty automated_emails response - no DB rows exist const emptyAutomatedEmailsFixture = { automated_emails: [] @@ -402,7 +325,7 @@ test.describe('Member emails settings', async () => { await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture} }}); @@ -426,15 +349,6 @@ test.describe('Member emails settings', async () => { }); test('Clicking Edit when no row exists creates inactive row then opens modal', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const emptyAutomatedEmailsFixture = { automated_emails: [] }; @@ -459,7 +373,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdAutomatedEmailResponse} }}); @@ -488,15 +402,6 @@ test.describe('Member emails settings', async () => { }); test('Clicking Edit when row exists does NOT create new row, just opens modal', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const existingAutomatedEmailsFixture = { automated_emails: [{ id: 'free-welcome-email-id', @@ -516,7 +421,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: existingAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: existingAutomatedEmailsFixture} }}); @@ -540,15 +445,6 @@ test.describe('Member emails settings', async () => { }); test('Toggle ON when no row exists creates active row', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const emptyAutomatedEmailsFixture = { automated_emails: [] }; @@ -572,7 +468,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: emptyAutomatedEmailsFixture}, addAutomatedEmail: {method: 'POST', path: '/automated_emails/', response: createdActiveResponse} }}); @@ -597,15 +493,6 @@ test.describe('Member emails settings', async () => { }); test('Toggle ON when inactive row exists updates to active', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const inactiveAutomatedEmailsFixture = { automated_emails: [{ id: 'free-welcome-email-id', @@ -632,7 +519,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: inactiveAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedActiveResponse} }}); @@ -657,15 +544,6 @@ test.describe('Member emails settings', async () => { }); test('Toggle OFF when active row exists updates to inactive', async ({page}) => { - const configResponse = { - config: { - ...responseFixtures.config.config, - labs: { - welcomeEmails: true - } - } - }; - const activeAutomatedEmailsFixture = { automated_emails: [{ id: 'free-welcome-email-id', @@ -692,7 +570,7 @@ test.describe('Member emails settings', async () => { const {lastApiRequests} = await mockApi({page, requests: { ...globalDataRequests, ...newslettersRequest, - browseConfig: {method: 'GET', path: '/config/', response: configResponse}, + browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config}, browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: activeAutomatedEmailsFixture}, editAutomatedEmail: {method: 'PUT', path: '/automated_emails/free-welcome-email-id/', response: updatedInactiveResponse} }}); 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/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js index 80df9032fb4..c93dedd10c6 100644 --- a/apps/portal/src/components/pages/magic-link-page.js +++ b/apps/portal/src/components/pages/magic-link-page.js @@ -4,6 +4,7 @@ import CloseButton from '../common/close-button'; import InboxLinkButton from '../common/inbox-link-button'; import AppContext from '../../app-context'; import {ReactComponent as EnvelopeIcon} from '../../images/icons/envelope.svg'; +import {isIos} from '../../utils/is-ios'; import {t} from '../../utils/i18n'; export const MagicLinkStyles = ` @@ -164,7 +165,7 @@ export default class MagicLinkPage extends React.Component { renderCloseButton() { const {site, inboxLinks} = this.context; const isInboxLinksEnabled = site.labs?.inboxlinks !== false; - if (isInboxLinksEnabled && inboxLinks) { + if (isInboxLinksEnabled && inboxLinks && !isIos(navigator)) { return ; } else { return ( @@ -278,7 +279,7 @@ export default class MagicLinkPage extends React.Component {