- {/* Table Header */}
-
-
- Member
-
-
- Status
-
- {showEmailOpenRate && (
-
- Open rate
-
- )}
-
- Location
-
-
- Created
-
-
-
- {/* Table Body */}
-
+
+
+
+
+
+ {showEmailOpenRate && }
+
+
+ {activeColumns.map(col => (
+
+ ))}
+
+
+
+
+ Member
+
+
+ Status
+
+ {showEmailOpenRate && (
+
+ Open rate
+
+ )}
+
+ Location
+
+
+ Created
+
+ {activeColumns.map(col => (
+
+ {col.label}
+
+ ))}
+
+
+
{visibleItems.map(({key, virtualItem, item, props}) => {
const shouldRenderPlaceholder =
@@ -122,16 +135,17 @@ function MembersList({
);
})}
-
-
+
+
);
}
diff --git a/apps/posts/src/views/members/member-query-params.ts b/apps/posts/src/views/members/member-query-params.ts
index b9bc0f77383..4fb788db18f 100644
--- a/apps/posts/src/views/members/member-query-params.ts
+++ b/apps/posts/src/views/members/member-query-params.ts
@@ -1,13 +1,24 @@
+import moment from 'moment-timezone';
import {memberFields} from './member-fields';
import {resolveField} from '../filters/resolve-field';
import type {FilterPredicate} from '../filters/filter-types';
+import type {Member, MemberSubscription} from '@tryghost/admin-x-framework/api/members';
-type ActiveColumn = {
+const MAX_ACTIVE_COLUMNS = 2;
+
+const ACTIVE_SUBSCRIPTION_STATUSES = new Set(['active', 'trialing', 'unpaid', 'past_due']);
+
+export type ActiveColumn = {
key: string;
label: string;
include?: string;
};
+export type ColumnValue = {
+ text: string;
+ subtext?: string;
+};
+
interface BuildMemberListSearchParamsOptions {
filters: FilterPredicate[];
nql?: string;
@@ -30,7 +41,7 @@ export function getMemberActiveColumns(filters: FilterPredicate[]): ActiveColumn
}
}
- return Array.from(columns.values());
+ return Array.from(columns.values()).slice(0, MAX_ACTIVE_COLUMNS);
}
function getMemberIncludes(filters: FilterPredicate[]): string {
@@ -77,3 +88,119 @@ export function buildMemberOperationParams({nql, search}: BuildMemberOperationPa
...(search ? {search} : {})
};
}
+
+export function mostRelevantSubscription(
+ subscriptions: MemberSubscription[] | undefined
+): MemberSubscription | null {
+ if (!subscriptions?.length) {
+ return null;
+ }
+
+ const withId = subscriptions.filter(s => s.id);
+
+ if (!withId.length) {
+ return null;
+ }
+
+ const sorted = [...withId].sort((a, b) => {
+ const aActive = ACTIVE_SUBSCRIPTION_STATUSES.has(a.status);
+ const bActive = ACTIVE_SUBSCRIPTION_STATUSES.has(b.status);
+
+ if (aActive && !bActive) {
+ return -1;
+ }
+ if (!aActive && bActive) {
+ return 1;
+ }
+
+ const aEnd = new Date(a.current_period_end).getTime();
+ const bEnd = new Date(b.current_period_end).getTime();
+
+ if (Number.isNaN(aEnd) && Number.isNaN(bEnd)) {
+ return 0;
+ }
+ if (Number.isNaN(aEnd)) {
+ return 1;
+ }
+ if (Number.isNaN(bEnd)) {
+ return -1;
+ }
+
+ return bEnd - aEnd;
+ });
+
+ return sorted[0];
+}
+
+function formatDateColumn(date: string | undefined, timezone: string): ColumnValue | null {
+ if (!date) {
+ return null;
+ }
+ return {
+ text: moment.tz(date, timezone).format('D MMM YYYY'),
+ subtext: moment(date).fromNow()
+ };
+}
+
+export function getActiveColumnValue(
+ column: ActiveColumn,
+ member: Member,
+ timezone: string
+): ColumnValue | null {
+ switch (column.key) {
+ case 'labels':
+ return member.labels?.length
+ ? {text: member.labels.map(l => l.name).join(', ')}
+ : null;
+
+ case 'tiers':
+ return member.tiers?.length
+ ? {text: member.tiers.map(t => t.name).join(', ')}
+ : null;
+
+ case 'subscriptions.plan_interval': {
+ const interval = mostRelevantSubscription(member.subscriptions)?.plan?.interval;
+ if (!interval) {
+ return null;
+ }
+ return {text: interval === 'month' ? 'Monthly' : 'Yearly'};
+ }
+
+ case 'subscriptions.status': {
+ const status = mostRelevantSubscription(member.subscriptions)?.status;
+ if (!status) {
+ return null;
+ }
+ return {
+ text: status
+ .split('_')
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+ };
+ }
+
+ case 'subscriptions.start_date':
+ return formatDateColumn(
+ mostRelevantSubscription(member.subscriptions)?.start_date,
+ timezone
+ );
+
+ case 'subscriptions.current_period_end':
+ return formatDateColumn(
+ mostRelevantSubscription(member.subscriptions)?.current_period_end,
+ timezone
+ );
+
+ case 'offer_redemptions': {
+ const offers = member.subscriptions
+ ?.map(s => s.offer?.name)
+ .filter(Boolean);
+ return offers?.length
+ ? {text: offers.join(', ')}
+ : null;
+ }
+
+ default:
+ return null;
+ }
+}
diff --git a/apps/posts/src/views/members/member-views.test.ts b/apps/posts/src/views/members/member-views.test.ts
index 9caeb8f1bbf..e2d096609a5 100644
--- a/apps/posts/src/views/members/member-views.test.ts
+++ b/apps/posts/src/views/members/member-views.test.ts
@@ -5,7 +5,7 @@ import {
isMemberViewSearchActive,
parseSharedViewsJSON
} from './member-views';
-import {describe, expect, it} from 'vitest';
+import {describe, expect, it, vi} from 'vitest';
describe('member-views', () => {
describe('parseSharedViewsJSON', () => {
@@ -43,6 +43,15 @@ describe('member-views', () => {
expect(result.ok).toBe(false);
});
+
+ it('falls back to an empty array when shared_views is not an array', () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const result = parseSharedViewsJSON('{}');
+
+ expect(result).toEqual({ok: true, views: []});
+
+ errorSpy.mockRestore();
+ });
});
describe('isMemberViewSearchActive', () => {
diff --git a/apps/posts/src/views/members/members.tsx b/apps/posts/src/views/members/members.tsx
index 5b17757708f..c9f54efa723 100644
--- a/apps/posts/src/views/members/members.tsx
+++ b/apps/posts/src/views/members/members.tsx
@@ -7,7 +7,7 @@ import MembersLayout from './components/members-layout';
import MembersList from './components/members-list';
import React, {useMemo} from 'react';
import {Button, EmptyIndicator, Header, LoadingIndicator, LucideIcon, cn} from '@tryghost/shade';
-import {buildMemberListSearchParams} from './member-query-params';
+import {buildMemberListSearchParams, getMemberActiveColumns} from './member-query-params';
import {canBulkDeleteMembers, shouldShowMembersLoading} from './members-view-state';
import {getSiteTimezone} from '@src/utils/get-site-timezone';
import {shouldDelayMembersDateFilterHydration, useMembersFilterState} from './hooks/use-members-filter-state';
@@ -26,6 +26,11 @@ const MembersPage: React.FC<{timezone: string}> = ({timezone}) => {
// Check if email analytics is enabled
const emailAnalyticsEnabled = configData?.config?.emailAnalytics === true;
+ // Extract active columns from filters (max 2)
+ const activeColumns = useMemo(() => {
+ return getMemberActiveColumns(filters);
+ }, [filters]);
+
// Check if bulk delete is permitted (not allowed if subscription filters are active)
const canBulkDelete = useMemo(() => {
return canBulkDeleteMembers(filters, nql);
@@ -157,12 +162,14 @@ const MembersPage: React.FC<{timezone: string}> = ({timezone}) => {
) : (
)}
diff --git a/apps/posts/src/views/members/shared-views.test.ts b/apps/posts/src/views/members/shared-views.test.ts
index e81ec8a5faf..ca3d576de1d 100644
--- a/apps/posts/src/views/members/shared-views.test.ts
+++ b/apps/posts/src/views/members/shared-views.test.ts
@@ -2,11 +2,24 @@ import {
type SharedView,
findMatchingSharedViewIndexes,
hasSharedViewNameConflict,
- isSharedViewEqual
+ isSharedViewEqual,
+ parseAllSharedViewsJSON
} from './shared-views';
-import {describe, expect, it} from 'vitest';
+import {describe, expect, it, vi} from 'vitest';
describe('shared-views', () => {
+ describe('parseAllSharedViewsJSON', () => {
+ it('falls back to an empty array and logs when shared_views is not an array', () => {
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const result = parseAllSharedViewsJSON('{}');
+
+ expect(result).toEqual({ok: true, views: []});
+ expect(errorSpy).toHaveBeenCalledWith('Failed to parse shared_views setting:', new Error('shared_views is not an array'));
+
+ errorSpy.mockRestore();
+ });
+ });
+
describe('isSharedViewEqual', () => {
it('matches views by route and filter payload', () => {
const left: SharedView = {
diff --git a/apps/posts/src/views/members/shared-views.ts b/apps/posts/src/views/members/shared-views.ts
index 3d8da316d36..36776d6fd60 100644
--- a/apps/posts/src/views/members/shared-views.ts
+++ b/apps/posts/src/views/members/shared-views.ts
@@ -55,7 +55,9 @@ export function parseAllSharedViewsJSON(json: string): AllSharedViewsParseResult
const parsed: unknown = JSON.parse(json);
if (!Array.isArray(parsed)) {
- return {ok: false, error: new Error('shared_views is not an array')};
+ // eslint-disable-next-line no-console
+ console.error('Failed to parse shared_views setting:', new Error('shared_views is not an array'));
+ return {ok: true, views: []};
}
const views = parsed.flatMap((item) => {
diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts
index 90e87f268f5..0848f8b3f50 100644
--- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts
+++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts
@@ -17,11 +17,33 @@ class SettingsMenu extends BasePage {
}
}
+class PublishFlow extends BasePage {
+ readonly publishButton: Locator;
+ readonly publishTypeSetting: Locator;
+ readonly publishTypeButton: Locator;
+ readonly emailRecipientsSetting: Locator;
+
+ constructor(page: Page) {
+ super(page);
+
+ this.publishButton = page.locator('[data-test-button="publish-flow"]').first();
+ this.publishTypeSetting = page.locator('[data-test-setting="publish-type"]');
+ this.publishTypeButton = this.publishTypeSetting.locator('> button');
+ this.emailRecipientsSetting = page.locator('[data-test-setting="email-recipients"]');
+ }
+
+ async open(): Promise
{
+ await this.publishButton.click();
+ }
+}
+
export class PostEditorPage extends AdminPage {
readonly titleInput: Locator;
+ readonly postStatus: Locator;
readonly previewButton: Locator;
readonly previewModal: PostPreviewModal;
readonly settingsToggleButton: Locator;
+ readonly publishFlow: PublishFlow;
readonly settingsMenu: SettingsMenu;
@@ -30,9 +52,11 @@ export class PostEditorPage extends AdminPage {
this.pageUrl = '/ghost/#/editor/post/';
this.titleInput = page.getByRole('textbox', {name: 'Post title'});
+ this.postStatus = page.locator('[data-test-editor-post-status]');
this.previewButton = page.getByRole('button', {name: 'Preview'});
this.previewModal = new PostPreviewModal(page);
this.settingsToggleButton = page.getByTestId('settings-menu-toggle');
+ this.publishFlow = new PublishFlow(page);
this.settingsMenu = new SettingsMenu(page);
}
diff --git a/e2e/helpers/pages/admin/posts/posts-page.ts b/e2e/helpers/pages/admin/posts/posts-page.ts
index b89fca6498a..cac588a4309 100644
--- a/e2e/helpers/pages/admin/posts/posts-page.ts
+++ b/e2e/helpers/pages/admin/posts/posts-page.ts
@@ -25,7 +25,7 @@ export class PostsPage extends AdminPage {
this.postsList = page.getByTestId('posts-list');
this.postsListItem = this.postsList.getByTestId('posts-list-item');
- this.newPostButton = page.getByRole('link', {name: 'New post'});
+ this.newPostButton = page.getByRole('link', {name: 'New post', exact: true});
this.postsFilters = page.getByTestId('posts-filters');
this.typeFilter = this.postsFilters.getByRole('button', {name: 'Type filter'});
diff --git a/e2e/helpers/pages/admin/settings/sections/index.ts b/e2e/helpers/pages/admin/settings/sections/index.ts
index e235c9a8294..73e9b78f6ad 100644
--- a/e2e/helpers/pages/admin/settings/sections/index.ts
+++ b/e2e/helpers/pages/admin/settings/sections/index.ts
@@ -1,3 +1,4 @@
+export {PrivateSiteSection} from './private-site-section';
export {PublicationSection} from './publications-section';
export {LabsSection} from './labs-section';
export {IntegrationsSection} from './integrations-section';
diff --git a/e2e/helpers/pages/admin/settings/sections/private-site-section.ts b/e2e/helpers/pages/admin/settings/sections/private-site-section.ts
new file mode 100644
index 00000000000..488008ec907
--- /dev/null
+++ b/e2e/helpers/pages/admin/settings/sections/private-site-section.ts
@@ -0,0 +1,33 @@
+import {BasePage} from '@/helpers/pages';
+import {Locator, Page} from '@playwright/test';
+
+export class PrivateSiteSection extends BasePage {
+ readonly section: Locator;
+ readonly editButton: Locator;
+ readonly saveButton: Locator;
+ readonly passwordToggle: Locator;
+ readonly passwordInput: Locator;
+
+ constructor(page: Page) {
+ super(page, '/ghost/#/settings');
+
+ this.section = page.getByTestId('locksite');
+ this.editButton = this.section.getByRole('button', {name: 'Edit'});
+ this.saveButton = this.section.getByRole('button', {name: 'Save'});
+ this.passwordToggle = this.section.getByLabel(/Enable password protection/);
+ this.passwordInput = this.section.getByLabel('Site password');
+ }
+
+ async enablePrivateMode(password: string): Promise {
+ await this.editButton.click();
+ await this.passwordToggle.check();
+ await this.passwordInput.fill(password);
+ await this.saveButton.click();
+ }
+
+ async disablePrivateMode(): Promise {
+ await this.editButton.click();
+ await this.passwordToggle.uncheck();
+ await this.saveButton.click();
+ }
+}
diff --git a/e2e/helpers/pages/portal/account-page.ts b/e2e/helpers/pages/portal/account-page.ts
index c4d5a0c47f6..d34253cbd87 100644
--- a/e2e/helpers/pages/portal/account-page.ts
+++ b/e2e/helpers/pages/portal/account-page.ts
@@ -5,6 +5,8 @@ export class PortalAccountPage extends PortalPage {
readonly title: Locator;
readonly billingInfoHeading: Locator;
readonly cancellationNotice: Locator;
+ readonly changePlanButton: Locator;
+ readonly viewPlansButton: Locator;
readonly resumeSubscriptionButton: Locator;
readonly canceledBadge: Locator;
readonly emailNewsletterHeading: Locator;
@@ -15,6 +17,8 @@ export class PortalAccountPage extends PortalPage {
this.title = this.portalFrame.getByRole('heading', {name: 'Your account'});
this.billingInfoHeading = this.portalFrame.getByRole('heading', {name: 'Billing info & receipts'});
this.cancellationNotice = this.portalFrame.getByText(/Your subscription has been canceled and will expire on/i);
+ this.changePlanButton = this.portalFrame.getByRole('button', {name: 'Change'});
+ this.viewPlansButton = this.portalFrame.getByRole('button', {name: 'View plans'});
this.resumeSubscriptionButton = this.portalFrame.getByRole('button', {name: 'Resume subscription'});
this.canceledBadge = this.portalFrame.getByText('Canceled', {exact: true});
this.emailNewsletterHeading = this.portalFrame.getByRole('heading', {name: 'Email newsletter'});
@@ -31,4 +35,17 @@ export class PortalAccountPage extends PortalPage {
planPrice(priceLabel: string): Locator {
return this.portalFrame.getByText(priceLabel);
}
+
+ async openChangePlan(): Promise {
+ await this.changePlanButton.click();
+ }
+
+ async openPlanSelection(): Promise {
+ if (await this.viewPlansButton.isVisible()) {
+ await this.viewPlansButton.click();
+ return;
+ }
+
+ await this.changePlanButton.click();
+ }
}
diff --git a/e2e/helpers/pages/portal/account-plan-page.ts b/e2e/helpers/pages/portal/account-plan-page.ts
new file mode 100644
index 00000000000..f438d7d9a2d
--- /dev/null
+++ b/e2e/helpers/pages/portal/account-plan-page.ts
@@ -0,0 +1,50 @@
+import {Locator, Page} from '@playwright/test';
+import {PortalPage} from './portal-page';
+
+export class PortalAccountPlanPage extends PortalPage {
+ readonly choosePlanTitle: Locator;
+ readonly title: Locator;
+ readonly monthlySwitchButton: Locator;
+ readonly yearlySwitchButton: Locator;
+ readonly confirmActionButton: Locator;
+
+ constructor(page: Page) {
+ super(page);
+
+ this.choosePlanTitle = this.portalFrame.getByRole('heading', {name: 'Choose a plan'});
+ this.title = this.portalFrame.getByRole('heading', {name: 'Change plan'});
+ this.monthlySwitchButton = this.portalFrame.locator('[data-test-button="switch-monthly"]');
+ this.yearlySwitchButton = this.portalFrame.locator('[data-test-button="switch-yearly"]');
+ this.confirmActionButton = this.portalFrame.locator('[data-test-button="confirm-action"]').first();
+ }
+
+ async waitUntilLoaded(): Promise {
+ await this.monthlySwitchButton.waitFor({state: 'visible'});
+ }
+
+ async switchCadence(cadence: 'monthly' | 'yearly'): Promise {
+ if (cadence === 'monthly') {
+ await this.monthlySwitchButton.click();
+ return;
+ }
+
+ await this.yearlySwitchButton.click();
+ }
+
+ async selectTier(name: string): Promise {
+ const tierCard = this.portalFrame.locator('[data-test-tier="paid"]').filter({hasText: name}).first();
+
+ await tierCard.waitFor({state: 'visible'});
+ await tierCard.locator('[data-test-button="select-tier"]').click();
+ }
+
+ async confirmAction(): Promise {
+ await this.confirmActionButton.click();
+ }
+
+ async confirmIfVisible(): Promise {
+ if (await this.confirmActionButton.isVisible()) {
+ await this.confirmActionButton.click();
+ }
+ }
+}
diff --git a/e2e/helpers/pages/portal/index.ts b/e2e/helpers/pages/portal/index.ts
index 71186426ff6..27390b9591e 100644
--- a/e2e/helpers/pages/portal/index.ts
+++ b/e2e/helpers/pages/portal/index.ts
@@ -1,5 +1,8 @@
export * from './account-page';
+export * from './account-plan-page';
export * from './portal-page';
export * from './sign-in-page';
export * from './sign-up-page';
export * from './sign-up-success-page';
+export * from './support-notification-page';
+export * from './support-success-page';
diff --git a/e2e/helpers/pages/portal/sign-up-page.ts b/e2e/helpers/pages/portal/sign-up-page.ts
index 3a9c84e44c9..ff060633305 100644
--- a/e2e/helpers/pages/portal/sign-up-page.ts
+++ b/e2e/helpers/pages/portal/sign-up-page.ts
@@ -2,6 +2,8 @@ import {Locator, Page} from '@playwright/test';
import {PortalPage} from './portal-page';
export class SignUpPage extends PortalPage {
+ readonly monthlySwitchButton: Locator;
+ readonly yearlySwitchButton: Locator;
readonly emailInput: Locator;
readonly nameInput: Locator;
readonly signupButton: Locator;
@@ -15,6 +17,8 @@ export class SignUpPage extends PortalPage {
constructor(page: Page) {
super(page);
+ this.monthlySwitchButton = this.portalFrame.locator('[data-test-button="switch-monthly"]');
+ this.yearlySwitchButton = this.portalFrame.locator('[data-test-button="switch-yearly"]');
this.nameInput = this.portalFrame.getByRole('textbox', {name: 'Name'});
this.emailInput = this.portalFrame.getByRole('textbox', {name: 'Email'});
this.signupButton = this.portalFrame.getByRole('button', {name: 'Sign up'});
@@ -34,18 +38,34 @@ export class SignUpPage extends PortalPage {
await this.signupButton.click();
}
- async fillAndSubmitPaidSignup(email: string, name?: string): Promise {
+ async fillAndSubmitPaidSignup(email: string, name?: string, tierName?: string): Promise {
if (name) {
await this.nameInput.fill(name);
}
await this.emailInput.fill(email);
- await this.selectPaidTier();
+ await this.selectPaidTier(tierName);
await this.continueIfVisible();
}
- async selectPaidTier(): Promise {
- await this.paidTierCard.waitFor({state: 'visible'});
- await this.paidTierSelectButton.click();
+ async selectPaidTier(tierName?: string): Promise {
+ const paidTierCard = tierName
+ ? this.portalFrame.locator('[data-test-tier="paid"]').filter({hasText: tierName}).first()
+ : this.paidTierCard;
+ const paidTierSelectButton = tierName
+ ? paidTierCard.locator('[data-test-button="select-tier"]')
+ : this.paidTierSelectButton;
+
+ await paidTierCard.waitFor({state: 'visible'});
+ await paidTierSelectButton.click();
+ }
+
+ async switchCadence(cadence: 'monthly' | 'yearly'): Promise {
+ if (cadence === 'monthly') {
+ await this.monthlySwitchButton.click();
+ return;
+ }
+
+ await this.yearlySwitchButton.click();
}
async continueIfVisible(): Promise {
diff --git a/e2e/helpers/pages/portal/support-notification-page.ts b/e2e/helpers/pages/portal/support-notification-page.ts
new file mode 100644
index 00000000000..a433e0dd589
--- /dev/null
+++ b/e2e/helpers/pages/portal/support-notification-page.ts
@@ -0,0 +1,14 @@
+import {BasePage} from '@/helpers/pages';
+import {FrameLocator, Locator, Page} from '@playwright/test';
+
+export class SupportNotificationPage extends BasePage {
+ readonly notificationFrame: FrameLocator;
+ readonly successMessage: Locator;
+
+ constructor(page: Page) {
+ super(page);
+
+ this.notificationFrame = page.frameLocator('[data-testid="portal-notification-frame"]');
+ this.successMessage = this.notificationFrame.getByText('Thank you for your support!');
+ }
+}
diff --git a/e2e/helpers/pages/portal/support-success-page.ts b/e2e/helpers/pages/portal/support-success-page.ts
new file mode 100644
index 00000000000..142e20eda0e
--- /dev/null
+++ b/e2e/helpers/pages/portal/support-success-page.ts
@@ -0,0 +1,14 @@
+import {Locator, Page} from '@playwright/test';
+import {PortalPage} from './portal-page';
+
+export class SupportSuccessPage extends PortalPage {
+ readonly title: Locator;
+ readonly signUpButton: Locator;
+
+ constructor(page: Page) {
+ super(page);
+
+ this.title = this.portalFrame.getByRole('heading', {name: 'Thank you for your support'});
+ this.signUpButton = this.portalFrame.getByRole('button', {name: 'Sign up'});
+ }
+}
diff --git a/e2e/helpers/pages/public/index.ts b/e2e/helpers/pages/public/index.ts
index c93c082f70d..14e0cd9fe73 100644
--- a/e2e/helpers/pages/public/index.ts
+++ b/e2e/helpers/pages/public/index.ts
@@ -1,4 +1,5 @@
export * from './home-page';
export * from './post-page';
export * from './post';
+export * from './private-site-page';
export * from './public-page';
diff --git a/e2e/helpers/pages/public/private-site-page.ts b/e2e/helpers/pages/public/private-site-page.ts
new file mode 100644
index 00000000000..64ef9bca747
--- /dev/null
+++ b/e2e/helpers/pages/public/private-site-page.ts
@@ -0,0 +1,20 @@
+import {BasePage} from '@/helpers/pages';
+import {Locator, Page} from '@playwright/test';
+
+export class PrivateSitePage extends BasePage {
+ readonly accessCodeLink: Locator;
+ readonly accessCodeDialog: Locator;
+ readonly enterButton: Locator;
+
+ constructor(page: Page) {
+ super(page, '/');
+
+ this.accessCodeLink = page.getByRole('link', {name: 'Enter access code'});
+ this.accessCodeDialog = page.getByRole('dialog', {name: 'Enter access code'});
+ this.enterButton = page.getByRole('button', {name: /Enter/});
+ }
+
+ async openAccessCodeDialog(): Promise {
+ await this.accessCodeLink.click();
+ }
+}
diff --git a/e2e/helpers/pages/public/public-page.ts b/e2e/helpers/pages/public/public-page.ts
index 3d2bfc977e8..9c05639c073 100644
--- a/e2e/helpers/pages/public/public-page.ts
+++ b/e2e/helpers/pages/public/public-page.ts
@@ -142,4 +142,8 @@ export class PublicPage extends BasePage {
async gotoPortalSignup(options?: pageGotoOptions): Promise {
return await this.goto('/#/portal/signup', options);
}
+
+ async gotoPortalSupport(options?: pageGotoOptions): Promise {
+ return await this.goto('/#/portal/support', options);
+ }
}
diff --git a/e2e/helpers/pages/stripe/fake-checkout-page.ts b/e2e/helpers/pages/stripe/fake-checkout-page.ts
index 5e43ab11714..481787b6f0a 100644
--- a/e2e/helpers/pages/stripe/fake-checkout-page.ts
+++ b/e2e/helpers/pages/stripe/fake-checkout-page.ts
@@ -3,14 +3,66 @@ import {Locator, Page} from '@playwright/test';
export class FakeStripeCheckoutPage extends BasePage {
readonly title: Locator;
+ readonly totalAmount: Locator;
+ readonly changeAmountButton: Locator;
+ readonly customAmountInput: Locator;
+ readonly emailInput: Locator;
+ readonly cardTabButton: Locator;
+ readonly submitButton: Locator;
constructor(page: Page) {
super(page);
this.title = page.getByRole('heading', {name: 'Fake Stripe Checkout'});
+ this.totalAmount = page.getByTestId('product-summary-total-amount');
+ this.changeAmountButton = page.getByRole('button', {name: 'Change amount'});
+ this.customAmountInput = page.locator('#customUnitAmount');
+ this.emailInput = page.locator('#email');
+ this.cardTabButton = page.getByTestId('card-tab-button');
+ this.submitButton = page.getByTestId('hosted-payment-submit-button');
}
async waitUntilLoaded(): Promise {
await this.title.waitFor({state: 'visible'});
}
+
+ async waitUntilDonationReady(): Promise {
+ await this.waitUntilLoaded();
+ await this.totalAmount.waitFor({state: 'visible'});
+ }
+
+ async changeAmountTo(amount: string): Promise {
+ if (!await this.customAmountInput.isVisible()) {
+ await this.changeAmountButton.click();
+ }
+ await this.customAmountInput.fill(amount);
+ }
+
+ async fillEmail(email: string): Promise {
+ await this.emailInput.fill(email);
+ }
+
+ async getEmail(): Promise {
+ return await this.emailInput.inputValue();
+ }
+
+ async getAmountInCents(): Promise {
+ if (!await this.customAmountInput.isVisible()) {
+ await this.changeAmountButton.click();
+ }
+
+ const value = await this.customAmountInput.inputValue();
+ const normalizedValue = value.replace(/[^0-9.]/g, '');
+ const parsed = Number.parseFloat(normalizedValue);
+
+ if (!Number.isFinite(parsed)) {
+ throw new Error(`Invalid donation amount: ${value}`);
+ }
+
+ return Math.round(parsed * 100);
+ }
+
+ async submitPayment(): Promise {
+ await this.submitButton.click();
+ }
}
diff --git a/e2e/helpers/playwright/flows/donations.ts b/e2e/helpers/playwright/flows/donations.ts
new file mode 100644
index 00000000000..1471e4652e7
--- /dev/null
+++ b/e2e/helpers/playwright/flows/donations.ts
@@ -0,0 +1,43 @@
+import {FakeStripeCheckoutPage} from '@/helpers/pages';
+import {Page} from '@playwright/test';
+import type {StripeTestService} from '@/helpers/services/stripe';
+
+interface CompleteDonationOptions {
+ amount?: string;
+ donationMessage?: string;
+ email?: string;
+ name?: string;
+}
+
+export async function completeDonationViaFakeCheckout(page: Page, stripe: StripeTestService, opts: CompleteDonationOptions = {}): Promise {
+ const checkoutPage = new FakeStripeCheckoutPage(page);
+ await checkoutPage.waitUntilDonationReady();
+
+ if (opts.amount) {
+ await checkoutPage.changeAmountTo(opts.amount);
+ }
+
+ if (opts.email) {
+ await checkoutPage.fillEmail(opts.email);
+ }
+
+ const amount = await checkoutPage.getAmountInCents();
+ const email = await checkoutPage.getEmail();
+
+ await checkoutPage.submitPayment();
+ await stripe.completeLatestDonationCheckout({
+ amount,
+ donationMessage: opts.donationMessage,
+ email,
+ name: opts.name
+ });
+
+ const latestCheckoutSession = stripe.getCheckoutSessions().at(-1);
+ const successUrl = latestCheckoutSession?.response.success_url;
+
+ if (!successUrl) {
+ throw new Error('Latest Stripe checkout session does not include a success URL');
+ }
+
+ await page.goto(successUrl);
+}
diff --git a/e2e/helpers/playwright/flows/index.ts b/e2e/helpers/playwright/flows/index.ts
index 64cc4ac9746..f61b14d8946 100644
--- a/e2e/helpers/playwright/flows/index.ts
+++ b/e2e/helpers/playwright/flows/index.ts
@@ -1,3 +1,5 @@
+export * from './donations';
export * from './sign-in';
export * from './signup';
export * from './tiers';
+export * from './upgrade';
diff --git a/e2e/helpers/playwright/flows/signup.ts b/e2e/helpers/playwright/flows/signup.ts
index 28c8a674b37..f63fa21629a 100644
--- a/e2e/helpers/playwright/flows/signup.ts
+++ b/e2e/helpers/playwright/flows/signup.ts
@@ -21,7 +21,12 @@ export async function signupViaPortal(page: Page): Promise<{emailAddress: string
return {emailAddress, name};
}
-export async function completePaidSignupViaPortal(page: Page, stripe: StripeTestService, opts?: {emailAddress?: string; name?: string}): Promise<{emailAddress: string; name: string}> {
+export async function completePaidSignupViaPortal(page: Page, stripe: StripeTestService, opts?: {
+ cadence?: 'monthly' | 'yearly';
+ emailAddress?: string;
+ name?: string;
+ tierName?: string;
+}): Promise<{emailAddress: string; name: string}> {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.openPortal();
@@ -31,7 +36,12 @@ export async function completePaidSignupViaPortal(page: Page, stripe: StripeTest
const name = opts?.name ?? faker.person.fullName();
await signUpPage.waitForPortalToOpen();
- await signUpPage.fillAndSubmitPaidSignup(emailAddress, name);
+
+ if (opts?.cadence) {
+ await signUpPage.switchCadence(opts.cadence);
+ }
+
+ await signUpPage.fillAndSubmitPaidSignup(emailAddress, name, opts?.tierName);
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.waitUntilLoaded();
diff --git a/e2e/helpers/playwright/flows/upgrade.ts b/e2e/helpers/playwright/flows/upgrade.ts
new file mode 100644
index 00000000000..32b022fd43a
--- /dev/null
+++ b/e2e/helpers/playwright/flows/upgrade.ts
@@ -0,0 +1,58 @@
+import {FakeStripeCheckoutPage, HomePage, PortalAccountPage, PortalAccountPlanPage} from '@/helpers/pages';
+import {signInAsMember} from './sign-in';
+import type {Member} from '@/data-factory';
+import type {Page} from '@playwright/test';
+import type {StripeTestService} from '@/helpers/services/stripe';
+
+function getLatestCheckoutSuccessUrl(stripe: StripeTestService): string {
+ const successUrl = stripe.getCheckoutSessions().at(-1)?.response.success_url;
+
+ if (!successUrl) {
+ throw new Error('Latest Stripe checkout session does not include a success URL');
+ }
+
+ return successUrl;
+}
+
+export async function completePaidUpgradeViaPortal(page: Page, stripe: StripeTestService, member: Member, opts: {
+ cadence: 'monthly' | 'yearly';
+ tierName: string;
+}): Promise {
+ await signInAsMember(page, member);
+
+ const homePage = new HomePage(page);
+ await homePage.openAccountPortal();
+
+ const portalAccountPage = new PortalAccountPage(page);
+ await portalAccountPage.waitForPortalToOpen();
+ await portalAccountPage.openPlanSelection();
+
+ const accountPlanPage = new PortalAccountPlanPage(page);
+ await accountPlanPage.waitUntilLoaded();
+ await accountPlanPage.switchCadence(opts.cadence);
+ await accountPlanPage.selectTier(opts.tierName);
+
+ const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
+ await fakeCheckoutPage.waitUntilLoaded();
+ await stripe.completeLatestSubscriptionCheckout({name: member.name ?? undefined});
+ await page.goto(getLatestCheckoutSuccessUrl(stripe));
+}
+
+export async function switchPlanViaPortal(page: Page, opts: {
+ cadence: 'monthly' | 'yearly';
+ tierName: string;
+}): Promise {
+ const homePage = new HomePage(page);
+ await homePage.goto();
+ await homePage.openAccountPortal();
+
+ const portalAccountPage = new PortalAccountPage(page);
+ await portalAccountPage.waitForPortalToOpen();
+ await portalAccountPage.openPlanSelection();
+
+ const accountPlanPage = new PortalAccountPlanPage(page);
+ await accountPlanPage.waitUntilLoaded();
+ await accountPlanPage.switchCadence(opts.cadence);
+ await accountPlanPage.selectTier(opts.tierName);
+ await accountPlanPage.confirmAction();
+}
diff --git a/e2e/helpers/services/settings/settings-service.ts b/e2e/helpers/services/settings/settings-service.ts
index 41113adc85e..e54e3f21238 100644
--- a/e2e/helpers/services/settings/settings-service.ts
+++ b/e2e/helpers/services/settings/settings-service.ts
@@ -2,7 +2,7 @@ import {HttpClient as APIRequest} from '@/data-factory';
export interface Setting {
key: string;
- value: string | boolean | string[] | null;
+ value: string | number | boolean | string[] | null;
}
export interface SettingsResponse {
@@ -63,6 +63,14 @@ export class SettingsService {
return await this.updateSettings([{key: 'portal_plans', value}]);
}
+ async setDonationsSuggestedAmount(value: number) {
+ return await this.updateSettings([{key: 'donations_suggested_amount', value: value.toString()}]);
+ }
+
+ async setDonationsCurrency(value: string) {
+ return await this.updateSettings([{key: 'donations_currency', value}]);
+ }
+
/**
* Set Stripe keys to simulate a connected Stripe account
* Uses direct Stripe keys (not Connect) as they're not filtered by the API
diff --git a/e2e/helpers/services/stripe/builders.ts b/e2e/helpers/services/stripe/builders.ts
index fec2204d89f..984cc2afa52 100644
--- a/e2e/helpers/services/stripe/builders.ts
+++ b/e2e/helpers/services/stripe/builders.ts
@@ -32,13 +32,16 @@ export type StripePaymentMethod = Omit, 'price'> & {
+ id: string;
+ object: 'subscription_item';
price: StripePrice;
};
-export type StripeSubscription = Omit, 'customer' | 'default_payment_method' | 'items'> & {
+export type StripeSubscription = Omit, 'customer' | 'default_payment_method' | 'items' | 'metadata'> & {
customer: string;
default_payment_method: string | null;
items: StripeList;
+ metadata: Stripe.Metadata;
};
export type StripeCustomer = Omit, 'email' | 'name'> & {
@@ -155,6 +158,7 @@ export function buildCustomer(opts: {id?: string; email: string; name: string}):
export function buildSubscription(opts: {
id?: string;
customerId: string;
+ itemId?: string;
paymentMethod?: StripePaymentMethod | null;
price?: StripePrice;
priceId?: string;
@@ -174,10 +178,17 @@ export function buildSubscription(opts: {
canceled_at: null,
current_period_end: Math.floor(Date.now() / 1000) + (60 * 60 * 24 * 31),
start_date: Math.floor(Date.now() / 1000),
+ trial_start: null,
+ trial_end: null,
+ metadata: {},
default_payment_method: opts.paymentMethod?.id ?? null,
items: {
object: 'list',
- data: [{price}]
+ data: [{
+ id: opts.itemId ?? generateId('si'),
+ object: 'subscription_item',
+ price
+ }]
},
customer: opts.customerId
};
diff --git a/e2e/helpers/services/stripe/fake-checkout-page-renderer.ts b/e2e/helpers/services/stripe/fake-checkout-page-renderer.ts
new file mode 100644
index 00000000000..d11122a9f1d
--- /dev/null
+++ b/e2e/helpers/services/stripe/fake-checkout-page-renderer.ts
@@ -0,0 +1,221 @@
+interface FakeCheckoutPageProps {
+ mode: string;
+ sessionId: string;
+}
+
+interface FakeDonationCheckoutPageProps extends FakeCheckoutPageProps {
+ amount: number;
+ billingName: string;
+ currency: string;
+ email: string;
+}
+
+function escapeHtml(value: string): string {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll('\'', ''');
+}
+
+function formatCurrency(amount: number, currency: string): string {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currency.toUpperCase()
+ }).format(amount / 100);
+}
+
+export function renderFakeCheckoutPage({mode, sessionId}: FakeCheckoutPageProps): string {
+ return `
+
+
+
+ Fake Stripe Checkout
+
+
+
+ Fake Stripe Checkout
+ Session: ${escapeHtml(sessionId)}
+ Mode: ${escapeHtml(mode)}
+
+
+ `;
+}
+
+export function renderFakeDonationCheckoutPage({
+ amount,
+ billingName,
+ currency,
+ email,
+ mode,
+ sessionId
+}: FakeDonationCheckoutPageProps): string {
+ const formattedAmount = formatCurrency(amount, currency);
+ const amountInputValue = (amount / 100).toFixed(2);
+
+ return `
+
+
+
+ Fake Stripe Checkout
+
+
+
+
+
+
+
+
+
+ `;
+}
diff --git a/e2e/helpers/services/stripe/fake-stripe-server.ts b/e2e/helpers/services/stripe/fake-stripe-server.ts
index fbb7de99eff..d638739a6ac 100644
--- a/e2e/helpers/services/stripe/fake-stripe-server.ts
+++ b/e2e/helpers/services/stripe/fake-stripe-server.ts
@@ -13,6 +13,7 @@ import {
buildPrice,
buildProduct
} from './builders';
+import {renderFakeCheckoutPage, renderFakeDonationCheckoutPage} from './fake-checkout-page-renderer';
const debug = baseDebug('e2e:fake-stripe');
@@ -72,6 +73,10 @@ export class FakeStripeServer {
return Array.from(this.customers.values());
}
+ getSubscriptions(): StripeSubscription[] {
+ return Array.from(this.subscriptions.values());
+ }
+
getCheckoutSessions(): RecordedStripeCheckoutSession[] {
return Array.from(this.checkoutSessions.values());
}
@@ -257,6 +262,108 @@ export class FakeStripeServer {
res.status(200).json(response);
});
+ this.app.post('/v1/subscriptions/:id', (req, res) => {
+ const subscriptionId = req.params.id;
+ const subscription = this.subscriptions.get(subscriptionId);
+
+ if (!subscription) {
+ debug(`Subscription not found for update: ${subscriptionId}`);
+ res.status(404).json({error: {type: 'invalid_request_error', message: 'No such subscription'}});
+ return;
+ }
+
+ const itemUpdates = this.parseSubscriptionItemsUpdate(req.body.items);
+ const metadata = this.applyMetadataUpdate(subscription.metadata, req.body.metadata);
+ const cancelAtPeriodEnd = this.parseOptionalBoolean(req.body.cancel_at_period_end);
+ const defaultPaymentMethod = this.parseString(req.body.default_payment_method);
+ const updatedSubscription: StripeSubscription = {
+ ...subscription,
+ metadata
+ };
+
+ if (cancelAtPeriodEnd !== undefined) {
+ updatedSubscription.cancel_at_period_end = cancelAtPeriodEnd;
+ }
+
+ if (defaultPaymentMethod !== undefined) {
+ if (defaultPaymentMethod !== '' && !this.paymentMethods.has(defaultPaymentMethod)) {
+ debug(`Cannot update subscription ${subscriptionId} with missing payment method: ${defaultPaymentMethod}`);
+ res.status(400).json({error: {type: 'invalid_request_error', message: 'No such payment method'}});
+ return;
+ }
+
+ updatedSubscription.default_payment_method = defaultPaymentMethod || null;
+ }
+
+ if (req.body.trial_end === 'now') {
+ updatedSubscription.trial_end = Math.floor(Date.now() / 1000);
+ if (updatedSubscription.status === 'trialing') {
+ updatedSubscription.status = 'active';
+ }
+ }
+
+ if (itemUpdates.length > 0) {
+ const updatedItems = updatedSubscription.items.data.map((item) => {
+ const itemUpdate = itemUpdates.find(update => update.id === item.id);
+
+ if (!itemUpdate) {
+ return item;
+ }
+
+ const price = this.prices.get(itemUpdate.price);
+ if (!price) {
+ return item;
+ }
+
+ return {
+ ...item,
+ price
+ };
+ });
+
+ const missingPriceId = itemUpdates.find((update) => {
+ return !this.prices.has(update.price);
+ })?.price;
+
+ if (missingPriceId) {
+ debug(`Cannot update subscription ${subscriptionId} with missing price: ${missingPriceId}`);
+ res.status(400).json({error: {type: 'invalid_request_error', message: 'No such price'}});
+ return;
+ }
+
+ updatedSubscription.items = {
+ ...updatedSubscription.items,
+ data: updatedItems
+ };
+ }
+
+ this.upsertSubscription(updatedSubscription);
+ debug(`Updated subscription: ${subscriptionId}`);
+ res.status(200).json(updatedSubscription);
+ });
+
+ this.app.delete('/v1/subscriptions/:id', (req, res) => {
+ const subscriptionId = req.params.id;
+ const subscription = this.subscriptions.get(subscriptionId);
+
+ if (!subscription) {
+ debug(`Subscription not found for delete: ${subscriptionId}`);
+ res.status(404).json({error: {type: 'invalid_request_error', message: 'No such subscription'}});
+ return;
+ }
+
+ const canceledSubscription: StripeSubscription = {
+ ...subscription,
+ status: 'canceled',
+ canceled_at: Math.floor(Date.now() / 1000),
+ cancel_at_period_end: false
+ };
+
+ this.upsertSubscription(canceledSubscription);
+ debug(`Deleted subscription: ${subscriptionId}`);
+ res.status(200).json(canceledSubscription);
+ });
+
this.app.get('/v1/payment_methods/:id', (req, res) => {
const paymentMethodId = req.params.id;
const paymentMethod = this.paymentMethods.get(paymentMethodId);
@@ -306,20 +413,25 @@ export class FakeStripeServer {
return;
}
- res.status(200).send(`
-
-
-
- Fake Stripe Checkout
-
-
-
- Fake Stripe Checkout
- Session: ${session.response.id}
- Mode: ${session.response.mode}
-
-
- `);
+ if (session.response.mode === 'payment') {
+ const price = this.getCheckoutPrice(session);
+ const customer = this.getCheckoutCustomer(session);
+
+ res.status(200).send(renderFakeDonationCheckoutPage({
+ amount: price?.custom_unit_amount?.preset ?? price?.unit_amount ?? 0,
+ billingName: customer?.name ?? 'Testy McTesterson',
+ currency: price?.currency ?? 'usd',
+ email: session.response.customer_email ?? customer?.email ?? '',
+ mode: session.response.mode,
+ sessionId: session.response.id
+ }));
+ return;
+ }
+
+ res.status(200).send(renderFakeCheckoutPage({
+ mode: session.response.mode,
+ sessionId: session.response.id
+ }));
});
this.app.post('/v1/billing_portal/configurations/:id?', (req, res) => {
@@ -338,6 +450,24 @@ export class FakeStripeServer {
return typeof value === 'string' ? value : undefined;
}
+ private getCheckoutPrice(session: RecordedStripeCheckoutSession): StripePrice | null {
+ const priceId = session.request.line_items?.[0]?.price ?? session.request.subscription_data?.items[0]?.plan;
+
+ if (!priceId) {
+ return null;
+ }
+
+ return this.prices.get(priceId) ?? null;
+ }
+
+ private getCheckoutCustomer(session: RecordedStripeCheckoutSession): StripeCustomer | null {
+ if (!session.response.customer) {
+ return null;
+ }
+
+ return this.customers.get(session.response.customer) ?? null;
+ }
+
private parseNumber(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
@@ -369,6 +499,14 @@ export class FakeStripeServer {
return fallback;
}
+ private parseOptionalBoolean(value: unknown): boolean | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+
+ return this.parseBoolean(value);
+ }
+
private parsePriceInterval(value: unknown): StripePrice['recurring'] extends {interval: infer T} | null ? T | undefined : never {
if (value !== 'day' && value !== 'week' && value !== 'month' && value !== 'year') {
return undefined;
@@ -395,6 +533,27 @@ export class FakeStripeServer {
);
}
+ private applyMetadataUpdate(currentMetadata: Record, value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return currentMetadata;
+ }
+
+ const metadata = {...currentMetadata};
+
+ for (const [key, entryValue] of Object.entries(value as Record)) {
+ if (entryValue === null) {
+ delete metadata[key];
+ continue;
+ }
+
+ if (typeof entryValue === 'string' || typeof entryValue === 'number' || typeof entryValue === 'boolean') {
+ metadata[key] = String(entryValue);
+ }
+ }
+
+ return metadata;
+ }
+
private parseCustomUnitAmount(value: unknown): StripePrice['custom_unit_amount'] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
@@ -461,6 +620,26 @@ export class FakeStripeServer {
.filter(item => item.price);
}
+ private parseSubscriptionItemsUpdate(value: unknown): Array<{id: string; price: string}> {
+ if (!value || typeof value !== 'object') {
+ return [];
+ }
+
+ const items = Array.isArray(value)
+ ? value
+ : Object.values(value as Record);
+
+ return items
+ .filter((item): item is {id?: string; price?: string} => item !== null && typeof item === 'object')
+ .map((item) => {
+ return {
+ id: this.parseString(item.id) ?? '',
+ price: this.parseString(item.price) ?? ''
+ };
+ })
+ .filter(item => item.id && item.price);
+ }
+
private parseCustomFields(value: unknown): RecordedStripeCheckoutSession['request']['custom_fields'] {
if (!value || typeof value !== 'object') {
return undefined;
diff --git a/e2e/helpers/services/stripe/stripe-service.ts b/e2e/helpers/services/stripe/stripe-service.ts
index c90defdf78f..03d8dc66d27 100644
--- a/e2e/helpers/services/stripe/stripe-service.ts
+++ b/e2e/helpers/services/stripe/stripe-service.ts
@@ -52,6 +52,10 @@ export class StripeTestService {
return this.server.getCustomers();
}
+ getSubscriptions(): StripeSubscription[] {
+ return this.server.getSubscriptions();
+ }
+
getCheckoutSessions(): RecordedStripeCheckoutSession[] {
return this.server.getCheckoutSessions();
}
diff --git a/e2e/tests/admin/posts/publish-flow.test.ts b/e2e/tests/admin/posts/publish-flow.test.ts
new file mode 100644
index 00000000000..18a9bbec307
--- /dev/null
+++ b/e2e/tests/admin/posts/publish-flow.test.ts
@@ -0,0 +1,23 @@
+import {PostEditorPage, PostsPage} from '@/admin-pages';
+import {SettingsService} from '@/helpers/services/settings/settings-service';
+import {expect, test} from '@/helpers/playwright';
+
+test.describe('Ghost Admin - Publish Flow', () => {
+ test('disabled subscription access hides membership features in publish flow', async ({page}) => {
+ const settingsService = new SettingsService(page.request);
+ await settingsService.setMembersSignupAccess('none');
+
+ const postsPage = new PostsPage(page);
+ await postsPage.goto();
+ await postsPage.newPostButton.click();
+
+ const editor = new PostEditorPage(page);
+ await editor.titleInput.fill('Test post');
+ await editor.titleInput.press('Enter');
+ await expect(editor.postStatus).toContainText('Draft - Saved');
+ await editor.publishFlow.open();
+
+ await expect(editor.publishFlow.publishTypeButton).toHaveCount(0);
+ await expect(editor.publishFlow.emailRecipientsSetting).toHaveCount(0);
+ });
+});
diff --git a/e2e/tests/admin/settings/private-site.test.ts b/e2e/tests/admin/settings/private-site.test.ts
new file mode 100644
index 00000000000..ba7a8a69c7a
--- /dev/null
+++ b/e2e/tests/admin/settings/private-site.test.ts
@@ -0,0 +1,40 @@
+import {HomePage, PrivateSitePage} from '@/helpers/pages';
+import {PrivateSiteSection, SettingsPage} from '@/admin-pages';
+import {expect, test, withIsolatedPage} from '@/helpers/playwright';
+import {usePerTestIsolation} from '@/helpers/playwright/isolation';
+
+usePerTestIsolation();
+
+test.describe('Ghost Admin - Private Site', () => {
+ test('private site requires password and can be made public again', async ({page, browser, baseURL}) => {
+ const settingsPage = new SettingsPage(page);
+ await settingsPage.goto();
+
+ const privateSiteSettings = new PrivateSiteSection(page);
+ await privateSiteSettings.enablePrivateMode('password');
+ await expect(privateSiteSettings.passwordInput).toHaveCount(0);
+
+ await withIsolatedPage(browser, {baseURL}, async ({page: frontendPage}) => {
+ const privateSite = new PrivateSitePage(frontendPage);
+ await privateSite.goto();
+
+ await expect(frontendPage).toHaveURL(/\/private\/\?r=%2F/);
+ await expect(privateSite.accessCodeLink).toBeVisible();
+
+ await privateSite.openAccessCodeDialog();
+ await expect(privateSite.accessCodeDialog).toBeVisible();
+ await expect(privateSite.enterButton).toBeVisible();
+ });
+
+ await privateSiteSettings.disablePrivateMode();
+ await expect(privateSiteSettings.passwordInput).toHaveCount(0);
+
+ await withIsolatedPage(browser, {baseURL}, async ({page: frontendPage}) => {
+ const site = new HomePage(frontendPage);
+ await expect(async () => {
+ await site.goto();
+ await expect(site.title).toBeVisible();
+ }).toPass();
+ });
+ });
+});
diff --git a/e2e/tests/public/portal-donations.test.ts b/e2e/tests/public/portal-donations.test.ts
new file mode 100644
index 00000000000..dbd1f83c535
--- /dev/null
+++ b/e2e/tests/public/portal-donations.test.ts
@@ -0,0 +1,92 @@
+import {
+ FakeStripeCheckoutPage,
+ HomePage,
+ SignUpPage,
+ SupportNotificationPage,
+ SupportSuccessPage
+} from '@/helpers/pages';
+import {SettingsService} from '@/helpers/services/settings/settings-service';
+import {
+ completeDonationViaFakeCheckout,
+ expect,
+ signInAsMember,
+ test
+} from '@/helpers/playwright';
+import {createMemberFactory} from '@/data-factory';
+
+test.describe('Ghost Public - Portal Donations', () => {
+ test.use({stripeEnabled: true});
+
+ test('anonymous donation completes in portal - shows donation success page', async ({page, stripe}) => {
+ const homePage = new HomePage(page);
+ await homePage.gotoPortalSupport();
+
+ const checkoutPage = new FakeStripeCheckoutPage(page);
+ await checkoutPage.waitUntilDonationReady();
+ await expect(checkoutPage.totalAmount).toHaveText('$5.00');
+
+ await completeDonationViaFakeCheckout(page, stripe!, {
+ amount: '12.50',
+ email: `member-donation-${Date.now()}@ghost.org`,
+ name: 'Test Member Donations'
+ });
+
+ const supportSuccessPage = new SupportSuccessPage(page);
+ await supportSuccessPage.waitForPortalToOpen();
+ await expect(supportSuccessPage.title).toBeVisible();
+
+ await supportSuccessPage.signUpButton.click();
+
+ const signUpPage = new SignUpPage(page);
+ await expect(signUpPage.emailInput).toBeVisible();
+ });
+
+ test('free member donation completes in portal - shows donation notification', async ({page, stripe}) => {
+ const memberFactory = createMemberFactory(page.request);
+ const member = await memberFactory.create({
+ email: `test.member.donations.${Date.now()}@example.com`,
+ name: 'Test Member Donations',
+ note: 'Test Member',
+ status: 'free'
+ });
+
+ await signInAsMember(page, member);
+
+ const homePage = new HomePage(page);
+ await homePage.gotoPortalSupport();
+
+ const checkoutPage = new FakeStripeCheckoutPage(page);
+ await checkoutPage.waitUntilDonationReady();
+ await expect(checkoutPage.emailInput).toHaveValue(member.email);
+
+ await completeDonationViaFakeCheckout(page, stripe!, {
+ amount: '12.50',
+ name: member.name ?? 'Test Member Donations'
+ });
+
+ const notificationPage = new SupportNotificationPage(page);
+ await expect(notificationPage.successMessage).toBeVisible();
+ });
+
+ test('fixed donation amount and currency open donation checkout - shows fixed euro amount', async ({page, stripe}) => {
+ const settingsService = new SettingsService(page.request);
+ await settingsService.setDonationsSuggestedAmount(9800);
+ await settingsService.setDonationsCurrency('EUR');
+
+ const homePage = new HomePage(page);
+ await homePage.gotoPortalSupport();
+
+ const checkoutPage = new FakeStripeCheckoutPage(page);
+ await checkoutPage.waitUntilDonationReady();
+ await expect(checkoutPage.totalAmount).toHaveText('€98.00');
+
+ await completeDonationViaFakeCheckout(page, stripe!, {
+ email: `member-donation-fixed-${Date.now()}@ghost.org`,
+ name: 'Fixed Amount Donor'
+ });
+
+ const supportSuccessPage = new SupportSuccessPage(page);
+ await supportSuccessPage.waitForPortalToOpen();
+ await expect(supportSuccessPage.title).toBeVisible();
+ });
+});
diff --git a/e2e/tests/public/portal-upgrade.test.ts b/e2e/tests/public/portal-upgrade.test.ts
new file mode 100644
index 00000000000..d3937e967ee
--- /dev/null
+++ b/e2e/tests/public/portal-upgrade.test.ts
@@ -0,0 +1,108 @@
+import {HomePage, PortalAccountPage} from '@/helpers/pages';
+import {
+ completePaidSignupViaPortal,
+ completePaidUpgradeViaPortal,
+ createPaidPortalTier,
+ expect,
+ switchPlanViaPortal,
+ test
+} from '@/helpers/playwright';
+import {createMemberFactory} from '@/data-factory';
+
+test.describe('Ghost Public - Portal Upgrade', () => {
+ test.use({stripeEnabled: true});
+
+ test('free member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
+ const memberFactory = createMemberFactory(page.request);
+ const tier = await createPaidPortalTier(page.request, {
+ name: `Free Upgrade Tier ${Date.now()}`,
+ currency: 'usd',
+ monthly_price: 500,
+ yearly_price: 5000
+ });
+ const member = await memberFactory.create({
+ email: `free-upgrade-${Date.now()}@example.com`,
+ name: 'Free Upgrade Member',
+ status: 'free'
+ });
+
+ await completePaidUpgradeViaPortal(page, stripe!, member, {
+ cadence: 'yearly',
+ tierName: tier.name
+ });
+
+ const homePage = new HomePage(page);
+ await homePage.goto();
+ await homePage.openAccountPortal();
+
+ const portalAccountPage = new PortalAccountPage(page);
+ await portalAccountPage.waitForPortalToOpen();
+ await expect(portalAccountPage.emailText(member.email)).toBeVisible();
+ await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
+ await expect(portalAccountPage.billingInfoHeading).toBeVisible();
+ await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
+ });
+
+ test('comped member upgrades to paid via portal - portal shows billing info', async ({page, stripe}) => {
+ const memberFactory = createMemberFactory(page.request);
+ const tier = await createPaidPortalTier(page.request, {
+ name: `Comped Upgrade Tier ${Date.now()}`,
+ currency: 'usd',
+ monthly_price: 500,
+ yearly_price: 5000
+ });
+ const member = await memberFactory.create({
+ email: `comped-upgrade-${Date.now()}@example.com`,
+ name: 'Comped Upgrade Member',
+ status: 'comped',
+ tiers: [{id: tier.id}]
+ });
+
+ await completePaidUpgradeViaPortal(page, stripe!, member, {
+ cadence: 'yearly',
+ tierName: tier.name
+ });
+
+ const homePage = new HomePage(page);
+ await homePage.goto();
+ await homePage.openAccountPortal();
+
+ const portalAccountPage = new PortalAccountPage(page);
+ await portalAccountPage.waitForPortalToOpen();
+ await expect(portalAccountPage.emailText(member.email)).toBeVisible();
+ await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
+ await expect(portalAccountPage.billingInfoHeading).toBeVisible();
+ await expect(portalAccountPage.cardLast4('4242')).toBeVisible();
+ });
+
+ test('paid member changes plan in portal - subscription switches between monthly and yearly', async ({page, stripe}) => {
+ const tier = await createPaidPortalTier(page.request, {
+ name: `Upgrade Tier ${Date.now()}`,
+ currency: 'usd',
+ monthly_price: 500,
+ yearly_price: 5000
+ });
+ const name = 'Portal Plan Switch Member';
+ const {emailAddress} = await completePaidSignupViaPortal(page, stripe!, {
+ cadence: 'yearly',
+ name,
+ tierName: tier.name
+ });
+
+ await switchPlanViaPortal(page, {
+ cadence: 'monthly',
+ tierName: tier.name
+ });
+
+ const portalAccountPage = new PortalAccountPage(page);
+ await expect(portalAccountPage.emailText(emailAddress)).toBeVisible();
+ await expect(portalAccountPage.planPrice('$5.00/month')).toBeVisible();
+
+ await switchPlanViaPortal(page, {
+ cadence: 'yearly',
+ tierName: tier.name
+ });
+
+ await expect(portalAccountPage.planPrice('$50.00/year')).toBeVisible();
+ });
+});
diff --git a/e2e/tests/public/stripe-donation-checkout.test.ts b/e2e/tests/public/stripe-donation-checkout.test.ts
index 6c7b1510f8d..2e3acbf028d 100644
--- a/e2e/tests/public/stripe-donation-checkout.test.ts
+++ b/e2e/tests/public/stripe-donation-checkout.test.ts
@@ -70,7 +70,7 @@ test.describe('Ghost Public - Stripe Donation Checkout', () => {
const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
await fakeCheckoutPage.goto(sessionResponse.url);
- await fakeCheckoutPage.waitUntilLoaded();
+ await fakeCheckoutPage.waitUntilDonationReady();
await stripe!.completeLatestDonationCheckout({
donationMessage,
diff --git a/e2e/tests/public/stripe-subscription-mutations.test.ts b/e2e/tests/public/stripe-subscription-mutations.test.ts
new file mode 100644
index 00000000000..bc967b23e03
--- /dev/null
+++ b/e2e/tests/public/stripe-subscription-mutations.test.ts
@@ -0,0 +1,123 @@
+import {FakeStripeCheckoutPage, PublicPage} from '@/helpers/pages';
+import {SignUpPage} from '@/portal-pages';
+import {createPaidPortalTier, expect, test} from '@/helpers/playwright';
+import type {Page} from '@playwright/test';
+
+async function getMemberIdentityToken(page: Page): Promise {
+ const response = await page.context().request.get('/members/api/session');
+
+ expect(response.ok()).toBe(true);
+
+ const identity = await response.text();
+ expect(identity).not.toBe('');
+
+ return identity;
+}
+
+function getAlternateCadence(interval: string | undefined): 'month' | 'year' {
+ if (interval === 'month') {
+ return 'year';
+ }
+
+ if (interval === 'year') {
+ return 'month';
+ }
+
+ throw new Error(`Unsupported subscription cadence: ${interval ?? 'missing'}`);
+}
+
+function getLatestCheckoutSuccessUrl(stripeCheckoutCount: {response: {success_url: string}}[]): string {
+ const successUrl = stripeCheckoutCount.at(-1)?.response.success_url;
+
+ if (!successUrl) {
+ throw new Error('Latest Stripe checkout session does not include a success URL');
+ }
+
+ return successUrl;
+}
+
+function getTargetPrice(targetCadence: 'month' | 'year', prices: {
+ monthly: {id: string};
+ yearly: {id: string};
+}): {id: string} {
+ if (targetCadence === 'month') {
+ return prices.monthly;
+ }
+
+ return prices.yearly;
+}
+
+test.describe('Ghost Public - Stripe Subscription Mutations', () => {
+ test.use({stripeEnabled: true});
+
+ test('paid member subscription update via ghost - switches the fake stripe price', async ({page, stripe}) => {
+ const memberEmail = `stripe-mutation-${Date.now()}@example.com`;
+ const tier = await createPaidPortalTier(page.request, {
+ name: `Mutation Tier ${Date.now()}`,
+ currency: 'usd',
+ monthly_price: 500,
+ yearly_price: 5000
+ });
+
+ await expect.poll(() => {
+ return stripe!.getProducts().find(item => item.name === tier.name);
+ }, {timeout: 10000}).toBeDefined();
+
+ await expect.poll(() => {
+ return stripe!.getPrices().filter(item => item.product === stripe!.getProducts().find(product => product.name === tier.name)?.id).length;
+ }, {timeout: 10000}).toBe(2);
+
+ const product = stripe!.getProducts().find(item => item.name === tier.name);
+ const monthlyPrice = stripe!.getPrices().find((item) => {
+ return item.product === product?.id && item.recurring?.interval === 'month';
+ });
+ const yearlyPrice = stripe!.getPrices().find((item) => {
+ return item.product === product?.id && item.recurring?.interval === 'year';
+ });
+
+ expect(product).toBeDefined();
+ expect(monthlyPrice).toBeDefined();
+ expect(yearlyPrice).toBeDefined();
+
+ const publicPage = new PublicPage(page);
+ await publicPage.gotoPortalSignup();
+
+ const signUpPage = new SignUpPage(page);
+ await signUpPage.waitForPortalToOpen();
+ await signUpPage.fillAndSubmitPaidSignup(memberEmail, 'Stripe Mutation Member', tier.name);
+
+ const fakeCheckoutPage = new FakeStripeCheckoutPage(page);
+ await fakeCheckoutPage.waitUntilLoaded();
+ await stripe!.completeLatestSubscriptionCheckout({name: 'Stripe Mutation Member'});
+ await page.goto(getLatestCheckoutSuccessUrl(stripe!.getCheckoutSessions()));
+
+ const subscription = stripe!.getSubscriptions().at(-1);
+ expect(subscription).toBeDefined();
+
+ const currentPrice = subscription!.items.data[0]?.price;
+ expect(currentPrice).toBeDefined();
+
+ const targetCadence = getAlternateCadence(currentPrice!.recurring?.interval);
+ const targetPrice = getTargetPrice(targetCadence, {
+ monthly: monthlyPrice!,
+ yearly: yearlyPrice!
+ });
+
+ expect(targetPrice).toBeDefined();
+
+ const identity = await getMemberIdentityToken(page);
+ const response = await page.context().request.put(`/members/api/subscriptions/${subscription!.id}/`, {
+ data: {
+ identity,
+ tierId: tier.id,
+ cadence: targetCadence
+ }
+ });
+
+ expect(response.status()).toBe(204);
+
+ const updatedSubscription = stripe!.getSubscriptions().find(item => item.id === subscription!.id);
+ expect(updatedSubscription?.items.data[0]?.price.id).toBe(targetPrice!.id);
+ expect(updatedSubscription?.items.data[0]?.price.recurring?.interval).toBe(targetCadence);
+ });
+});
diff --git a/ghost/admin/package.json b/ghost/admin/package.json
index 389253f97e4..2c4cb54579d 100644
--- a/ghost/admin/package.json
+++ b/ghost/admin/package.json
@@ -50,7 +50,7 @@
"@tryghost/helpers": "1.1.97",
"@tryghost/kg-clean-basic-html": "4.2.21",
"@tryghost/kg-converters": "1.1.20",
- "@tryghost/koenig-lexical": "1.7.25",
+ "@tryghost/koenig-lexical": "1.7.27",
"@tryghost/limit-service": "1.4.1",
"@tryghost/members-csv": "2.0.3",
"@tryghost/nql": "0.12.10",
diff --git a/ghost/core/core/server/adapters/storage/S3Storage.ts b/ghost/core/core/server/adapters/storage/S3Storage.ts
index 65f80f6b183..c4007d68226 100644
--- a/ghost/core/core/server/adapters/storage/S3Storage.ts
+++ b/ghost/core/core/server/adapters/storage/S3Storage.ts
@@ -310,14 +310,14 @@ export default class S3Storage extends StorageBase {
return relativePath.slice(this.storagePath.length + 1);
}
- async exists(fileName: string, targetDir: string): Promise {
+ async exists(fileName: string, targetDir?: string): Promise {
if (!fileName?.trim()) {
throw new errors.IncorrectUsageError({
message: tpl(messages.emptyFileName, {method: 'exists'})
});
}
- const relativePath = path.posix.join(targetDir, fileName);
+ const relativePath = targetDir ? path.posix.join(targetDir, fileName) : fileName;
const key = this.buildKey(relativePath);
try {
@@ -348,14 +348,14 @@ export default class S3Storage extends StorageBase {
};
}
- async delete(fileName: string, targetDir: string): Promise {
+ async delete(fileName: string, targetDir?: string): Promise {
if (!fileName?.trim()) {
throw new errors.IncorrectUsageError({
message: tpl(messages.emptyFileName, {method: 'delete'})
});
}
- const relativePath = path.posix.join(targetDir, fileName);
+ const relativePath = targetDir ? path.posix.join(targetDir, fileName) : fileName;
const key = this.buildKey(relativePath);
try {
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
index db336699304..6a3bf109ffe 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/mappers/posts.js
@@ -8,6 +8,7 @@ const clean = require('../utils/clean');
const date = require('../utils/date');
const extraAttrs = require('../utils/extra-attrs');
const gating = require('../utils/post-gating');
+const previewRendering = require('../utils/preview-rendering');
const url = require('../utils/url');
const utils = require('../../../index');
@@ -80,6 +81,7 @@ module.exports = async (model, frame, options = {}) => {
if (utils.isContentAPI(frame)) {
date.forPost(jsonModel);
gating.forPost(jsonModel, frame);
+ previewRendering.forPost(jsonModel, frame);
if (jsonModel.access) {
if (commentsService?.api?.enabled !== 'off') {
diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js
index 0e3c3f716bc..cd5d2dffc1b 100644
--- a/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js
+++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/utils/post-gating.js
@@ -116,16 +116,6 @@ const forPost = (attrs, frame) => {
attrs.html = attrs.html.replace(/%7Buuid%7D/gi, member.uuid);
}
- // In preview mode, replace Transistor iframe (and its accompanying script + noscript)
- // with a static placeholder since the embed requires a real member UUID to function
- if (frame.isPreview && attrs.html && attrs.html.includes('data-kg-transistor-embed')) {
- attrs.html = attrs.html.replace(
- /