diff --git a/apps/admin-x-framework/src/api/config.ts b/apps/admin-x-framework/src/api/config.ts index 618352db554..210743aac12 100644 --- a/apps/admin-x-framework/src/api/config.ts +++ b/apps/admin-x-framework/src/api/config.ts @@ -103,6 +103,15 @@ export type Config = { logoDark?: string; // Logo shown in dark mode, falls back to logo logoAlt?: string; // Alt text for the logo }; + // Payment-failure (dunning) state for the site's hosting subscription. + // Managed hosting providers set this while a payment is outstanding; + // Admin escalates from a warning banner to a locked overlay based on + // the position within the paymentFailedAt -> suspendsAt window. + dunning?: { + active?: boolean; // Only true while the payment is outstanding + paymentFailedAt?: string; // ISO date the payment first failed (window start) + suspendsAt?: string; // ISO date the host will suspend the site (window end) + }; // Search entries for billing paths, defined in host config (hostSettings.billing.search: {}) search?: { groupName?: string; diff --git a/apps/admin-x-framework/src/api/dunning.ts b/apps/admin-x-framework/src/api/dunning.ts new file mode 100644 index 00000000000..4cd6faa194f --- /dev/null +++ b/apps/admin-x-framework/src/api/dunning.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +/** + * sessionStorage keys the React admin's dunning UI and the Ember billing + * service handshake through — one side writes, the other consumes. Defined + * here so the cross-app contract lives in one place. + */ + +/** Route a "Pay now" CTA was clicked on; the post-payment return lands there. */ +export const DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY = 'ghost-dunning-pay-return-route'; + +/** `paymentFailedAt` of a failure settled by a completed payment this session. */ +export const DUNNING_PAYMENT_SETTLED_STORAGE_KEY = 'ghost-dunning-payment-settled-for'; + +const dateString = z + .string() + .transform((value) => new Date(value)) + .pipe(z.date()); + +const dunningConfigSchema = z + .object({ + active: z.literal(true), + paymentFailedAt: dateString, + suspendsAt: dateString, + }) + .refine(({ paymentFailedAt, suspendsAt }) => suspendsAt.getTime() > paymentFailedAt.getTime()); + +/** Shared by React warnings and the legacy alert so invalid config never hides both. */ +export function parseDunningConfig(value: unknown) { + const result = dunningConfigSchema.safeParse(value); + return result.success ? result.data : null; +} diff --git a/apps/admin-x-framework/src/api/members.ts b/apps/admin-x-framework/src/api/members.ts index e3108006af1..1f2dca1ab79 100644 --- a/apps/admin-x-framework/src/api/members.ts +++ b/apps/admin-x-framework/src/api/members.ts @@ -7,6 +7,7 @@ import { createQuery, createQueryWithId, } from '../utils/api/hooks'; +import { escapeNqlString } from '@tryghost/nql-string'; import { apiUrl, type RequestOptions } from '../utils/api/fetch-api'; import type { FieldValue } from '@tryghost/metafield-types'; import { useCurrentUser } from './current-user'; @@ -872,8 +873,15 @@ function memberEventsCursor(events: MemberActivityEvent[]): string | undefined { return new Date(createdAt).toISOString().slice(0, 19).replace('T', ' '); } -function buildMemberEventsFilter(memberId: string): string { - return `data.member_id:'${memberId}'`; +// The same exclusion the full activity page sends, so the preview and the page leave out +// the same events. +function buildMemberEventsFilter(memberId: string, excludedEvents: string[]): string { + return [ + excludedEvents.length > 0 && `type:-[${excludedEvents.map(escapeNqlString).join(',')}]`, + `data.member_id:'${memberId}'`, + ] + .filter(Boolean) + .join('+'); } const useMemberActivityFeedQuery = createInfiniteQuery({ @@ -914,12 +922,12 @@ const useMemberActivityFeedQuery = createInfiniteQuery { + it.each([ + undefined, + null, + { active: true }, + { ...validConfig, active: false }, + { ...validConfig, active: 'false' }, + { ...validConfig, paymentFailedAt: 'invalid' }, + { ...validConfig, paymentFailedAt: 1 }, + { ...validConfig, suspendsAt: true }, + { ...validConfig, suspendsAt: validConfig.paymentFailedAt }, + { ...validConfig, suspendsAt: '2026-08-01' }, + ])('rejects unusable host config: %j', (config) => { + expect(parseDunningConfig(config)).toBeNull(); + }); + + it('pins the storage keys both apps handshake through', () => { + // These literals are the on-the-wire contract between the React admin and + // the Ember billing service; renaming the constants must not change them. + expect(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY).toBe('ghost-dunning-pay-return-route'); + expect(DUNNING_PAYMENT_SETTLED_STORAGE_KEY).toBe('ghost-dunning-payment-settled-for'); + }); + + it('parses valid dates, including host timezone offsets', () => { + expect( + parseDunningConfig({ ...validConfig, paymentFailedAt: '2026-09-01T04:00:00+04:00' }), + ).toEqual({ + active: true, + paymentFailedAt: new Date(validConfig.paymentFailedAt), + suspendsAt: new Date(validConfig.suspendsAt), + }); + }); +}); diff --git a/apps/admin/src/automations/components/automation-header.tsx b/apps/admin/src/automations/components/automation-header.tsx index ad16898da9c..5cd0b6338cf 100644 --- a/apps/admin/src/automations/components/automation-header.tsx +++ b/apps/admin/src/automations/components/automation-header.tsx @@ -1,5 +1,6 @@ import AutomationStatusBadge from './automation-status-badge'; import React from 'react'; +import { useShade } from '@tryghost/shade/app'; import { Button, type ButtonProps, Skeleton } from '@tryghost/shade/components'; import { Link } from '@tryghost/admin-x-framework'; import { LucideIcon } from '@tryghost/shade/utils'; @@ -36,13 +37,14 @@ const AutomationHeader: React.FC = ({ onPublish, onTurnOff, }) => { + const { isAdmin7 } = useShade(); const name = automation?.name; const status = automation?.status; return (
- + +
+ +
+

+ {lockedHeadline(state.daysLeft)} +

+ + {lockedMessage(state, isOwner)} + + {isOwner ? ( + + + + + ) : ( + owner && + )} +
+
+ ); +} diff --git a/apps/admin/src/dunning/dunning-ui.test.tsx b/apps/admin/src/dunning/dunning-ui.test.tsx new file mode 100644 index 00000000000..f8be3723671 --- /dev/null +++ b/apps/admin/src/dunning/dunning-ui.test.tsx @@ -0,0 +1,366 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { browseConfigWithDunning, dunningWindow } from '@test-utils/fixtures/dunning'; + +import { DunningBanner } from './dunning-banner'; +import { DunningOverlay } from './dunning-overlay'; + +const { + mockUseBrowseConfig, + mockUseSubscriptionStatus, + mockUseCurrentUser, + mockUseBrowseUsers, + mockUseLocation, +} = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), + mockUseSubscriptionStatus: vi.fn(), + mockUseCurrentUser: vi.fn(), + mockUseBrowseUsers: vi.fn(), + mockUseLocation: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: mockUseLocation, +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +vi.mock('@tryghost/admin-x-framework/api/current-user', async () => { + const actual = await vi.importActual< + typeof import('@tryghost/admin-x-framework/api/current-user') + >('@tryghost/admin-x-framework/api/current-user'); + return { ...actual, useCurrentUser: mockUseCurrentUser }; +}); + +vi.mock('@tryghost/admin-x-framework/api/users', async () => { + const actual = await vi.importActual( + '@tryghost/admin-x-framework/api/users', + ); + return { ...actual, useBrowseUsers: mockUseBrowseUsers }; +}); + +vi.mock('@/ember-bridge', () => ({ + useSubscriptionStatus: mockUseSubscriptionStatus, +})); + +const NOW = new Date('2026-09-10T12:00:00Z'); + +const ownerUser = { + id: 'owner-id', + name: 'Aileen', + email: 'owner@example.com', + roles: [{ name: 'Owner' }], +}; +const editorUser = { + id: 'editor-id', + email: 'editor@example.com', + roles: [{ name: 'Editor' }], +}; + +describe('dunning UI', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + window.sessionStorage.clear(); + mockUseSubscriptionStatus.mockReturnValue(null); + mockUseLocation.mockReturnValue({ pathname: '/analytics' }); + mockUseCurrentUser.mockReturnValue({ data: ownerUser }); + mockUseBrowseUsers.mockReturnValue({ data: { users: [ownerUser, editorUser] } }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('DunningBanner', () => { + test('renders nothing without dunning config', () => { + mockUseBrowseConfig.mockReturnValue({ data: { config: { hostSettings: {} } } }); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('shows the owner a Pay now link to the billing app', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + render(); + + expect(screen.getByText('Your payment didn’t go through.')).toBeInTheDocument(); + expect(screen.getByText(/26 days left/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Pay now' })).toHaveAttribute( + 'href', + '#/pro/update-card/return', + ); + }); + + test('shows staff the remind-the-owner copy without any CTA', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + + render(); + + expect(screen.getByText('This site’s payment failed.')).toBeInTheDocument(); + expect(screen.getByText(/Remind the site owner/)).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + test('renders nothing on the billing route', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + mockUseLocation.mockReturnValue({ pathname: '/pro/billing' }); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('hands over to the takeover once the locked phase starts', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render(); + + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + }); + + test('carries the warning on the export route while the takeover stands down', () => { + // Undismissed locked phase on /settings/migration: the takeover stands + // down so the export tools stay usable — the banner must step in, or + // the user is left with no payment warning at all. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/settings/migration' }); + + render( + <> + + + , + ); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + }); + }); + + describe('DunningOverlay', () => { + test('renders nothing during the warning phase', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + // Mounted on every Admin page, so the user list must not be fetched + // outside the staff-facing takeover + expect(mockUseBrowseUsers).toHaveBeenCalledWith(expect.objectContaining({ enabled: false })); + }); + + test('takes over for the owner in the locked phase', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render(); + + expect(screen.getByText('Your site will be suspended in 6 days')).toBeInTheDocument(); + expect(screen.getByText(/avoid suspension/)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Pay now' })).toHaveAttribute( + 'href', + '#/pro/update-card/return', + ); + expect(screen.getByRole('link', { name: 'Download my data' })).toHaveAttribute( + 'href', + '#/settings/migration', + ); + }); + + test('stands down on the export route so the data download stays reachable', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/settings/migration' }); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + }); + + test('shows staff the owner card instead of a payment link', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + + render(); + + expect(screen.getByText('Aileen (Owner)')).toBeInTheDocument(); + expect(screen.getByText('owner@example.com')).toBeInTheDocument(); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(mockUseBrowseUsers).toHaveBeenCalledWith({ + enabled: true, + searchParams: { filter: "roles.name:'Owner'", limit: '1', include: 'roles' }, + }); + }); + + test('labels the owner card by email when the owner has no name', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + mockUseBrowseUsers.mockReturnValue({ data: { users: [{ ...ownerUser, name: '' }] } }); + + render(); + + expect(screen.getByText('owner@example.com (Owner)')).toBeInTheDocument(); + }); + + test('degrades to copy only when staff cannot resolve the owner', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseCurrentUser.mockReturnValue({ data: editorUser }); + mockUseBrowseUsers.mockReturnValue({ data: undefined }); + + render(); + + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.getByText(/Remind the site owner/)).toBeInTheDocument(); + expect(screen.queryByText(/\(Owner\)/)).not.toBeInTheDocument(); + }); + + test('stands down on the billing route so the user can pay', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + mockUseLocation.mockReturnValue({ pathname: '/pro' }); + + render(); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + }); + + test('moves focus into the dialog and hands it back on dismissal', () => { + // Start outside the locked phase with focus on a page control + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + const view = render( + <> + + + , + ); + screen.getByTestId('page-control').focus(); + + // The window crosses into the locked phase: the takeover appears and + // takes keyboard focus so Tab starts inside the dialog + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + view.rerender( + <> + + + , + ); + expect(screen.getByTestId('dunning-overlay')).toHaveFocus(); + + // Dismissing hands focus back to the control that had it + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('page-control')).toHaveFocus(); + }); + + test('leaves focus alone when the previously focused control is gone', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + const view = render( + <> + + + , + ); + screen.getByTestId('page-control').focus(); + + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + view.rerender( + <> + + + , + ); + expect(screen.getByTestId('dunning-overlay')).toHaveFocus(); + + // The control disappears while the takeover is up (e.g. its screen + // re-rendered); dismissal must not try to focus a detached node + view.rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(document.body).toHaveFocus(); + }); + + test('dismissing drops back to the urgent warning banner', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + render( + <> + + + , + ); + + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + expect(screen.getByText('Action needed: payment failed.')).toBeInTheDocument(); + }); + + test('following Pay now suppresses the takeover without a pre-navigation flash', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const view = render( + <> + + + , + ); + + fireEvent.click(screen.getByRole('link', { name: 'Pay now' })); + + // Nothing swaps before the route change: the takeover holds the screen + // until the billing route takes over. + expect(screen.getByTestId('dunning-overlay')).toBeInTheDocument(); + expect(screen.queryByTestId('dunning-banner')).not.toBeInTheDocument(); + + // The click records where to return after the payment (consumed by the + // Ember billing service's previousPage handling) + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).toBe('/analytics'); + + // On the billing route both stand down as usual. + mockUseLocation.mockReturnValue({ pathname: '/pro/update-card' }); + view.rerender( + <> + + + , + ); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + + // Leaving billing again, the recorded suppression kicks in: the banner + // carries the message and the takeover stays away. + mockUseLocation.mockReturnValue({ pathname: '/analytics' }); + view.rerender( + <> + + + , + ); + expect(screen.queryByTestId('dunning-overlay')).not.toBeInTheDocument(); + expect(screen.getByTestId('dunning-banner')).toBeInTheDocument(); + }); + + test('shows the imminent headline when the suspension date has passed', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(30))); + + render(); + + expect(screen.getByText('Your site will be suspended soon')).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/admin/src/dunning/dunning.acceptance.test.tsx b/apps/admin/src/dunning/dunning.acceptance.test.tsx new file mode 100644 index 00000000000..8d6ce126918 --- /dev/null +++ b/apps/admin/src/dunning/dunning.acceptance.test.tsx @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import { page } from 'vitest/browser'; +import { + configResponse, + currentUserResponse, + fakeTags, + fakeUsers, + renderAdminApp, + staffRole, + staffUser, +} from '@test-utils/acceptance'; +import { tagsScreen } from '@/tags/tags.screen'; +import { dunningWindow } from '@test-utils/fixtures/dunning'; + +function configWithDunning(dunning?: unknown) { + const response = configResponse(); + return { + config: { + ...response.config, + hostSettings: { + billing: { enabled: true, dunning }, + }, + }, + }; +} + +describe('Dunning in the Admin layout', () => { + it.each([ + { label: 'an older backend without dunning config', enabled: true, dunning: undefined }, + { label: 'malformed dunning config', enabled: true, dunning: { active: true } }, + { label: 'the feature flag disabled', enabled: false, dunning: dunningWindow(22) }, + ])('keeps normal navigation usable with $label', async ({ enabled, dunning }) => { + fakeTags([]); + await renderAdminApp('/tags', { + labs: { dunningWarnings: enabled }, + boot: { browseConfig: { response: configWithDunning(dunning) } }, + }); + + await expect.element(tagsScreen.newTagLink()).toBeVisible(); + await expect(page.getByTestId('dunning-banner')).toHaveCount(0); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await tagsScreen.internalTab().click(); + await expect.element(tagsScreen.internalTab()).toHaveAttribute('aria-checked', 'true'); + }); + + it('shows the owner a warning and payment link before the takeover', async () => { + fakeTags([]); + await renderAdminApp('/tags', { + labs: { dunningWarnings: true }, + boot: { browseConfig: { response: configWithDunning(dunningWindow(2)) } }, + }); + + await expect.element(page.getByTestId('dunning-banner')).toBeVisible(); + await expect + .element(page.getByRole('link', { name: 'Pay now' })) + .toHaveAttribute('href', '#/pro/update-card/return'); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + }); + + it('fetches only the owner for the staff takeover and restores the page on dismissal', async () => { + fakeTags([]); + // Declare the owner-only response and assert the server-side filter below; + // the fake deliberately does not implement NQL or depend on staff ordering. + const owner = staffUser({ + name: 'Site Owner', + email: 'owner@example.com', + roles: [staffRole({ name: 'Owner' })], + }); + const usersApi = fakeUsers([owner]); + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: 'Editor' })]; + await renderAdminApp('/tags', { + labs: { dunningWarnings: true }, + boot: { + browseConfig: { response: configWithDunning(dunningWindow(22)) }, + browseMe: { response: me }, + }, + }); + + await expect.element(page.getByRole('alertdialog')).toBeVisible(); + await expect.element(page.getByText('owner@example.com', { exact: true })).toBeVisible(); + await expect(usersApi).toHaveSentFilter("roles.name:'Owner'"); + expect(usersApi.lastRequest?.limit).toBe(1); + expect(usersApi.requests).toHaveLength(1); + await page.getByRole('button', { name: 'Dismiss', exact: true }).click(); + await expect(page.getByRole('alertdialog')).toHaveCount(0); + await expect.element(page.getByTestId('dunning-banner')).toBeVisible(); + await tagsScreen.internalTab().click(); + await expect.element(tagsScreen.internalTab()).toHaveAttribute('aria-checked', 'true'); + }); +}); diff --git a/apps/admin/src/dunning/index.ts b/apps/admin/src/dunning/index.ts new file mode 100644 index 00000000000..62a7eb7a44b --- /dev/null +++ b/apps/admin/src/dunning/index.ts @@ -0,0 +1,4 @@ +export { DunningBanner } from './dunning-banner'; +export { DunningOverlay } from './dunning-overlay'; +export { useDunningLockTakeover } from './use-dunning-lock-takeover'; +export { useDunningState } from './use-dunning-state'; diff --git a/apps/admin/src/dunning/minute-ticker.test.ts b/apps/admin/src/dunning/minute-ticker.test.ts new file mode 100644 index 00000000000..51f05fcd102 --- /dev/null +++ b/apps/admin/src/dunning/minute-ticker.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { readSharedNow, retainMinuteTicker, subscribeSharedNow } from './minute-ticker'; + +describe('minute ticker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('refreshes the reading and starts ticking on first retention', () => { + vi.setSystemTime(new Date('2026-09-10T12:00:00Z')); + + const release = retainMinuteTicker(); + + expect(readSharedNow()).toBe(new Date('2026-09-10T12:00:00Z').getTime()); + expect(vi.getTimerCount()).toBe(1); + + release(); + }); + + test('notifies subscribers on each tick while retained', () => { + const release = retainMinuteTicker(); + const listener = vi.fn(); + const unsubscribe = subscribeSharedNow(listener); + const start = readSharedNow(); + + vi.advanceTimersByTime(120_000); + + expect(listener).toHaveBeenCalledTimes(2); + expect(readSharedNow()).toBe(start + 120_000); + + unsubscribe(); + release(); + }); + + test('stops notifying an unsubscribed listener', () => { + const release = retainMinuteTicker(); + const listener = vi.fn(); + subscribeSharedNow(listener)(); + + vi.advanceTimersByTime(60_000); + + expect(listener).not.toHaveBeenCalled(); + release(); + }); + + test('runs one interval however many consumers retain it', () => { + const releaseFirst = retainMinuteTicker(); + const releaseSecond = retainMinuteTicker(); + expect(vi.getTimerCount()).toBe(1); + + releaseFirst(); + expect(vi.getTimerCount()).toBe(1); + + releaseSecond(); + expect(vi.getTimerCount()).toBe(0); + }); + + test('a double release cannot stop a later retention', () => { + const releaseFirst = retainMinuteTicker(); + releaseFirst(); + releaseFirst(); + + const releaseSecond = retainMinuteTicker(); + expect(vi.getTimerCount()).toBe(1); + + releaseSecond(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/apps/admin/src/dunning/minute-ticker.ts b/apps/admin/src/dunning/minute-ticker.ts new file mode 100644 index 00000000000..07094bff191 --- /dev/null +++ b/apps/admin/src/dunning/minute-ticker.ts @@ -0,0 +1,66 @@ +/** + * One shared once-a-minute clock for every dunning surface. + * + * Each `useDunningState` instance used to run its own interval with its own + * `Date.now()` anchor, so at a phase boundary the banner, the takeover and + * the layout could disagree for up to a minute. Reading `now` from a single + * store means every consumer derives the phase from the same reading, and a + * boundary flips all surfaces in the same render pass. + * + * The clock only ticks while at least one consumer retains it, so sessions + * without dunning in effect carry no interval at all. `retainMinuteTicker` + * refreshes the reading immediately on the first retention — the stored + * value may be as old as the module load otherwise. + */ + +const TICK_MS = 60_000; + +let sharedNow = Date.now(); +let interval: ReturnType | null = null; +let retainers = 0; + +const listeners = new Set<() => void>(); + +function tick(): void { + sharedNow = Date.now(); + listeners.forEach((listener) => listener()); +} + +/** `useSyncExternalStore` snapshot: the shared clock's last reading. */ +export function readSharedNow(): number { + return sharedNow; +} + +/** `useSyncExternalStore` subscription to the shared clock's ticks. */ +export function subscribeSharedNow(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** + * Keeps the clock ticking until the returned release is called. The release + * is idempotent, so an effect cleanup cannot double-decrement the count. + */ +export function retainMinuteTicker(): () => void { + retainers += 1; + if (retainers === 1) { + tick(); + interval = setInterval(tick, TICK_MS); + } + + let released = false; + return () => { + if (released) { + return; + } + released = true; + + retainers -= 1; + if (retainers === 0 && interval) { + clearInterval(interval); + interval = null; + } + }; +} diff --git a/apps/admin/src/dunning/pay-now-button.tsx b/apps/admin/src/dunning/pay-now-button.tsx new file mode 100644 index 00000000000..b2808698b32 --- /dev/null +++ b/apps/admin/src/dunning/pay-now-button.tsx @@ -0,0 +1,29 @@ +import { Button } from '@tryghost/shade/components'; +import { useLocation } from '@tryghost/admin-x-framework'; +import type { DunningState } from './use-dunning-state'; +import { dismissLockQuietly, markPayNowReturnRoute } from './use-dunning-state'; +import { PAY_URL } from './dunning-copy'; + +/** + * The owner's "Pay now" CTA into the billing app's payment page. Following it + * counts as seeing the message — the locked takeover stays suppressed for the + * session (quietly, since the navigation repaints anyway) — and the clicked-from + * route is recorded so the post-payment return lands back on it. + */ +export function PayNowButton({ size, state }: { size: 'sm' | 'lg'; state: DunningState }) { + const location = useLocation(); + + return ( + + ); +} diff --git a/apps/admin/src/dunning/stand-down-routes.ts b/apps/admin/src/dunning/stand-down-routes.ts new file mode 100644 index 00000000000..e7f1e44e2ec --- /dev/null +++ b/apps/admin/src/dunning/stand-down-routes.ts @@ -0,0 +1,27 @@ +/** + * Routes where the dunning surfaces stand down so their own content stays + * usable — the takeover on both, the banner on the billing routes only. + */ + +/** + * The billing app's own routes: the dunning UI stands down there so the user + * can actually reach the payment form (and the billing app shows its own + * outstanding-invoice state). + */ +export function isBillingRoute(pathname: string): boolean { + return pathname === '/pro' || pathname.startsWith('/pro/'); +} + +/** + * The settings section holding the content-export tools — also the target of + * the takeover's "Download my data" CTA, so the two stay in step. + */ +export const DATA_EXPORT_ROUTE = '/settings/migration'; + +/** + * The locked overlay stands down on the export tools too, so its + * "Download my data" CTA leads somewhere usable. + */ +export function isDataExportRoute(pathname: string): boolean { + return pathname === DATA_EXPORT_ROUTE || pathname.startsWith(`${DATA_EXPORT_ROUTE}/`); +} diff --git a/apps/admin/src/dunning/use-blocking-modal.test.ts b/apps/admin/src/dunning/use-blocking-modal.test.ts new file mode 100644 index 00000000000..2085c2c1ed1 --- /dev/null +++ b/apps/admin/src/dunning/use-blocking-modal.test.ts @@ -0,0 +1,88 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +import { useBlockingModal } from './use-blocking-modal'; + +describe('useBlockingModal', () => { + const added: Element[] = []; + + const addModalMarker = (build: (element: HTMLDivElement) => void): HTMLDivElement => { + const element = document.createElement('div'); + build(element); + document.body.appendChild(element); + added.push(element); + return element; + }; + + afterEach(() => { + added.splice(0).forEach((element) => element.remove()); + document.body.style.pointerEvents = ''; + }); + + test('reports no blocking modal on a clean page', () => { + const { result } = renderHook(() => useBlockingModal(true)); + + expect(result.current).toBe(false); + }); + + test('reports a legacy Settings modal until its backdrop is removed', async () => { + const backdrop = addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + backdrop.remove(); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + test.each(['dialog', 'alertdialog'])( + 'reports an open %s until it leaves the open state', + async (role) => { + const dialog = addModalMarker((element) => { + element.setAttribute('role', role); + element.setAttribute('data-state', 'open'); + }); + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + dialog.setAttribute('data-state', 'closed'); + + await waitFor(() => expect(result.current).toBe(false)); + }, + ); + + test('waits for the Radix pointer lock to release after the dialog closes', async () => { + document.body.style.pointerEvents = 'none'; + const { result } = renderHook(() => useBlockingModal(true)); + expect(result.current).toBe(true); + + document.body.style.pointerEvents = ''; + + await waitFor(() => expect(result.current).toBe(false)); + }); + + test('notifies every consumer of the shared observer', async () => { + const first = renderHook(() => useBlockingModal(true)); + const second = renderHook(() => useBlockingModal(true)); + + addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + + await waitFor(() => expect(first.result.current).toBe(true)); + await waitFor(() => expect(second.result.current).toBe(true)); + }); + + test('ignores every marker while disabled', () => { + addModalMarker((element) => { + element.id = 'modal-backdrop'; + }); + document.body.style.pointerEvents = 'none'; + + const { result } = renderHook(() => useBlockingModal(false)); + + expect(result.current).toBe(false); + }); +}); diff --git a/apps/admin/src/dunning/use-blocking-modal.ts b/apps/admin/src/dunning/use-blocking-modal.ts new file mode 100644 index 00000000000..da980237511 --- /dev/null +++ b/apps/admin/src/dunning/use-blocking-modal.ts @@ -0,0 +1,51 @@ +import { useSyncExternalStore } from 'react'; + +// Match the Shade and legacy modal markers used by Settings. The dunning +// takeover itself has neither marker, so it cannot suppress itself. +const MODAL_SELECTOR = + '#modal-backdrop, :is([role="dialog"], [role="alertdialog"])[data-state="open"]'; + +function hasBlockingModal(): boolean { + // Radix retains its pointer lock through a dialog's exit animation. Wait for + // that lock to release as well as for the dialog to close. + return ( + document.body.style.pointerEvents === 'none' || Boolean(document.querySelector(MODAL_SELECTOR)) + ); +} + +const listeners = new Set<() => void>(); +let observer: MutationObserver | null = null; + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + if (!observer) { + observer = new MutationObserver(() => { + listeners.forEach((notify) => notify()); + }); + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-state', 'role', 'id', 'style'], + }); + } + + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = null; + } + }; +} + +const subscribeDisabled = () => () => {}; +const readDisabled = () => false; + +/** Share one observer across the layout, banner and overlay, only while locked. */ +export function useBlockingModal(enabled: boolean): boolean { + return useSyncExternalStore( + enabled ? subscribe : subscribeDisabled, + enabled ? hasBlockingModal : readDisabled, + ); +} diff --git a/apps/admin/src/dunning/use-dunning-lock-takeover.ts b/apps/admin/src/dunning/use-dunning-lock-takeover.ts new file mode 100644 index 00000000000..a2e337c6074 --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-lock-takeover.ts @@ -0,0 +1,31 @@ +import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; +import { useLocation } from '@tryghost/admin-x-framework'; +import { useDunningState } from './use-dunning-state'; +import { isBillingRoute, isDataExportRoute } from './stand-down-routes'; +import { useBlockingModal } from './use-blocking-modal'; + +/** + * Whether the dunning locked takeover is in effect for the current route: + * the overlay is showing and the surrounding chrome (sidebar) should read as + * disabled. Stands down on the billing and export routes so their content + * stays usable, and once the user has dismissed the takeover — the urgent + * warning banner carries the message from there. An existing modal keeps its + * focus and pointer ownership until it closes, then the takeover can appear. + */ +export function useDunningLockTakeover(): boolean { + const { data: currentUser } = useCurrentUser(); + const state = useDunningState(); + const location = useLocation(); + + const shouldTakeOver = Boolean( + state && + state.phase === 'locked' && + !state.lockDismissed && + currentUser && + !isBillingRoute(location.pathname) && + !isDataExportRoute(location.pathname), + ); + const modalOpen = useBlockingModal(shouldTakeOver); + + return shouldTakeOver && !modalOpen; +} diff --git a/apps/admin/src/dunning/use-dunning-state.test.ts b/apps/admin/src/dunning/use-dunning-state.test.ts new file mode 100644 index 00000000000..1c04a4298fc --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-state.test.ts @@ -0,0 +1,308 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { DAY_MS, browseConfigWithDunning, dunningWindow } from '@test-utils/fixtures/dunning'; + +import { dismissLock, markPayNowReturnRoute, useDunningState } from './use-dunning-state'; + +const { mockUseBrowseConfig, mockUseSubscriptionStatus } = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn(), + mockUseSubscriptionStatus: vi.fn(), +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig, +})); + +vi.mock('@/ember-bridge', () => ({ + useSubscriptionStatus: mockUseSubscriptionStatus, +})); + +const NOW = new Date('2026-09-10T12:00:00Z'); + +describe('useDunningState', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + window.sessionStorage.clear(); + mockUseSubscriptionStatus.mockReturnValue(null); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test('returns null without a dunning block', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(undefined)); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('returns null while the dunningWarnings flag is off', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2), {})); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('returns null when the block is inactive', () => { + mockUseBrowseConfig.mockReturnValue( + browseConfigWithDunning({ ...dunningWindow(2), active: false }), + ); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test.each([ + ['unparseable dates', { active: true, paymentFailedAt: 'nope', suspendsAt: 'also nope' }], + ['missing dates', { active: true }], + // A misconfigured host may send a truthy non-boolean; only `true` activates + ['a non-boolean active value', { ...dunningWindow(2), active: 'false' }], + [ + 'an inverted window', + { + active: true, + paymentFailedAt: NOW.toISOString(), + suspendsAt: new Date(NOW.getTime() - DAY_MS).toISOString(), + }, + ], + ])('returns null for %s', (_label, dunning) => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunning)); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('reports the warning phase early in the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: false, daysLeft: 26 }); + }); + + test('escalates to urgent styling past a quarter of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(8))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: true }); + }); + + test('stays in the urgent warning phase through the middle of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(14))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning', urgent: true, daysLeft: 14 }); + }); + + test('locks for the last quarter of the window', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', daysLeft: 6 }); + }); + + test('stays locked with zero days left when suspendsAt has passed', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(30))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', daysLeft: 0 }); + }); + + test('clears when the billing app reports an active subscription', () => { + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'active' } }); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toBeNull(); + }); + + test('does not clear for a subscription that is still past_due', () => { + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'past_due' } }); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).not.toBeNull(); + }); + + test.each([-60, 60])( + 'clears the settled failure with the client clock skewed by %i days', + (skewDays) => { + const dunning = dunningWindow(8, { now: NOW.getTime() }); + vi.setSystemTime(new Date(NOW.getTime() + skewDays * DAY_MS)); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunning)); + mockUseSubscriptionStatus.mockReturnValue({ subscription: { status: 'past_due' } }); + // Written by the Ember billing service on the post-payment return. + window.sessionStorage.setItem('ghost-dunning-payment-settled-for', dunning.paymentFailedAt); + + const { result, rerender } = renderHook(() => useDunningState()); + expect(result.current).toBeNull(); + + // A subsequent failure re-arms even when the browser clock is far ahead. + mockUseBrowseConfig.mockReturnValue( + browseConfigWithDunning(dunningWindow(2, { now: NOW.getTime() })), + ); + rerender(); + expect(result.current).not.toBeNull(); + }, + ); + + test('does not suppress a different failure even if its timestamp is older', () => { + window.sessionStorage.setItem( + 'ghost-dunning-payment-settled-for', + dunningWindow(2).paymentFailedAt, + ); + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked' }); + }); + + test('ignores the old browser-clock settlement marker', () => { + window.sessionStorage.setItem( + 'ghost-dunning-payment-settled-at', + new Date(NOW.getTime() + 60 * DAY_MS).toISOString(), + ); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'warning' }); + }); + + test('records a lock dismissal for the current episode', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: false }); + + act(() => { + dismissLock(result.current!); + }); + + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: true }); + }); + + test('a new payment failure resets the lock dismissal', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result, rerender } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + expect(result.current).toMatchObject({ lockDismissed: true }); + + // A later episode carries a different paymentFailedAt. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(23))); + rerender(); + + expect(result.current).toMatchObject({ lockDismissed: false }); + }); + + test('ticks the countdown down while dunning is in effect', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const { result } = renderHook(() => useDunningState()); + expect(result.current).toMatchObject({ daysLeft: 26 }); + + act(() => { + vi.advanceTimersByTime(DAY_MS + 60_000); + }); + + expect(result.current).toMatchObject({ daysLeft: 25 }); + }); + + test('shares one tick across every consumer of the hook', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2))); + + const first = renderHook(() => useDunningState()); + const second = renderHook(() => useDunningState()); + expect(vi.getTimerCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(DAY_MS + 60_000); + }); + + // Both instances read the same clock, so phase boundaries flip together. + expect(first.result.current?.daysLeft).toBe(25); + expect(second.result.current?.daysLeft).toBe(25); + + first.unmount(); + expect(vi.getTimerCount()).toBe(1); + second.unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + + test('installs no periodic tick when there is nothing to derive', () => { + // The hook mounts in the admin layout on every page: without dunning in + // effect a tick would re-render every session each minute for nothing. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(2), {})); + + renderHook(() => useDunningState()); + + expect(vi.getTimerCount()).toBe(0); + }); + + describe('when sessionStorage is unavailable', () => { + beforeEach(() => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('still reports state, with nothing read as settled or dismissed', () => { + // A window no other test dismisses, so the module-level in-memory + // fallback from earlier dismissals cannot match this episode. + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(24))); + + const { result } = renderHook(() => useDunningState()); + + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: false }); + }); + + test('dismissing the lock still works for the lifetime of the page', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(21))); + + const { result } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + + // The in-memory fallback carries the dismissal. + expect(result.current).toMatchObject({ phase: 'locked', lockDismissed: true }); + }); + + test('recording the Pay now return route swallows the failure', () => { + // Without storage the post-payment return falls back to the overview. + expect(() => markPayNowReturnRoute('/analytics')).not.toThrow(); + }); + }); + + test('keeps reporting state after a dismissal so the warning banner stays up', () => { + mockUseBrowseConfig.mockReturnValue(browseConfigWithDunning(dunningWindow(22))); + + const { result } = renderHook(() => useDunningState()); + act(() => { + dismissLock(result.current!); + }); + + expect(result.current).toMatchObject({ phase: 'locked', urgent: true, lockDismissed: true }); + }); +}); diff --git a/apps/admin/src/dunning/use-dunning-state.ts b/apps/admin/src/dunning/use-dunning-state.ts new file mode 100644 index 00000000000..8049682024f --- /dev/null +++ b/apps/admin/src/dunning/use-dunning-state.ts @@ -0,0 +1,204 @@ +import { useEffect, useSyncExternalStore } from 'react'; +import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; +import { + DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + parseDunningConfig, +} from '@tryghost/admin-x-framework/api/dunning'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; +import { useSubscriptionStatus } from '@/ember-bridge'; +import { readSharedNow, retainMinuteTicker, subscribeSharedNow } from './minute-ticker'; + +export type DunningPhase = 'warning' | 'locked'; + +export interface DunningState { + phase: DunningPhase; + /** Whole days until suspension, never negative. */ + daysLeft: number; + /** Escalated styling within the warning phase (last stretch before the lock). */ + urgent: boolean; + /** The locked takeover was stood down this session (closed, or a Pay now CTA followed). */ + lockDismissed: boolean; + paymentFailedAt: Date; + suspendsAt: Date; +} + +/** + * The locked overlay takes over once this fraction of the + * paymentFailedAt -> suspendsAt window has elapsed. Proportional rather than + * an absolute day count so the same component fits any host's window length. + */ +const LOCK_AT_FRACTION = 0.75; + +/** The warning banner escalates its styling past this fraction of the window. */ +const URGENT_AT_FRACTION = 0.25; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const LOCK_DISMISSED_KEY = 'ghost-dunning-lock-dismissed-for'; + +const dunningStoreListeners = new Set<() => void>(); + +// Fallback when sessionStorage is unavailable, so dismissing still works for +// the lifetime of the page. +let inMemoryLockDismissedFor: string | null = null; + +function readLockDismissedFor(): string | null { + try { + return window.sessionStorage.getItem(LOCK_DISMISSED_KEY); + } catch { + return inMemoryLockDismissedFor; + } +} + +/** + * One subscription serves every dunning snapshot: `dismissLock` is the only + * writer that notifies. The payment-settled snapshot is written by the Ember + * billing service without a notification on purpose — the return navigation + * triggers the render that picks it up (useSyncExternalStore re-reads its + * snapshot on every render). + */ +function subscribeDunningStore(listener: () => void): () => void { + dunningStoreListeners.add(listener); + return () => { + dunningStoreListeners.delete(listener); + }; +} + +function writeLockDismissedFor(state: DunningState): void { + inMemoryLockDismissedFor = state.paymentFailedAt.toISOString(); + try { + window.sessionStorage.setItem(LOCK_DISMISSED_KEY, inMemoryLockDismissedFor); + } catch { + // Storage can be unavailable; the in-memory fallback covers this page. + } +} + +/** + * Stands the locked takeover down for this session, swapping it out for the + * urgent warning banner right away. Keyed by `paymentFailedAt` and scoped to + * the session, so a later session — or a new payment failure — brings the + * takeover back. + */ +export function dismissLock(state: DunningState): void { + writeLockDismissedFor(state); + dunningStoreListeners.forEach((listener) => listener()); +} + +/** + * The "Pay now" variant of dismissLock: records the suppression without + * forcing an immediate re-render. The click is followed by navigation to the + * billing route, where the takeover and banner stand down anyway — an eager + * swap would flash the underlying screen with the warning banner for a frame + * before the route change lands. useSyncExternalStore re-reads its snapshot + * on every render, so the route change itself picks the stored value up. + */ +export function dismissLockQuietly(state: DunningState): void { + writeLockDismissedFor(state); +} + +/** + * Records the route a "Pay now" CTA was clicked on. The billing app's + * post-payment `previousPage` request is resolved from this on the Ember + * side — an explicit route rather than history.back(), so a deep link (or a + * tab whose history points outside Admin) falls back to the billing overview + * instead of leaving Ghost. + */ +export function markPayNowReturnRoute(route: string): void { + try { + window.sessionStorage.setItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, route); + } catch { + // Without storage the post-payment return falls back to the overview. + } +} + +/** + * Written by the Ember billing service when the billing app reports a + * completed payment (its `previousPage` return request only follows one). + * Read here so the warnings stand down the moment the user lands back, + * rather than lingering until the webhook-settled subscription state + * arrives seconds later. The return navigation triggers the render that + * picks the value up — no notification needed. + */ +function readPaymentSettledFor(): string | null { + try { + return window.sessionStorage.getItem(DUNNING_PAYMENT_SETTLED_STORAGE_KEY); + } catch { + return null; + } +} + +/** + * Payment-failure (dunning) state for the site's hosting subscription, derived + * from host-provided config (`hostSettings.billing.dunning`). + * + * Returns `null` when there is nothing to show: no dunning block, a malformed + * one (the /config/ response isn't runtime-validated, so guard against a + * misconfigured host config), or a live subscription that has become active + * (the billing app reports payment over the Ember bridge before the server + * config catches up). + * + * The phase is computed client-side from the position within the + * paymentFailedAt -> suspendsAt window so no config rewrite is needed for the + * warning -> locked transition. + */ +export function useDunningState(): DunningState | null { + const { data: config } = useBrowseConfig(); + const subscriptionStatus = useSubscriptionStatus(); + // Labs-gated while in development: hosts can ship and test the config + // pipeline without end users seeing any dunning UI. + const dunningWarningsEnabled = useFeatureFlag('dunningWarnings'); + + const dunning = dunningWarningsEnabled + ? parseDunningConfig(config?.config.hostSettings?.billing?.dunning) + : null; + const dunningInEffect = Boolean(dunning); + + // Re-derive the phase and countdown periodically; transitions land on date + // boundaries, so a coarse tick keeps them fresh without churn. The tick is + // shared so every consumer of this hook reads the same `now` and phase + // boundaries flip all surfaces together — and it is only retained while + // dunning is in effect, since this hook mounts in the admin layout and an + // unconditional interval would re-render every session each minute. + const now = useSyncExternalStore(subscribeSharedNow, readSharedNow); + useEffect(() => { + if (!dunningInEffect) { + return; + } + return retainMinuteTicker(); + }, [dunningInEffect]); + + const lockDismissedFor = useSyncExternalStore(subscribeDunningStore, readLockDismissedFor); + const paymentSettledFor = useSyncExternalStore(subscribeDunningStore, readPaymentSettledFor); + + if (!dunning) { + return null; + } + const { paymentFailedAt, suspendsAt } = dunning; + + // The billing app reported a live, active subscription: payment went + // through, only the restart-scoped config is stale. + if (subscriptionStatus?.subscription?.status === 'active') { + return null; + } + + // Only suppress the failure that was settled this session. Comparing its + // server-provided identity avoids relying on the browser clock and lets a + // different paymentFailedAt re-arm the warnings. + if (paymentSettledFor === paymentFailedAt.toISOString()) { + return null; + } + + const windowMs = suspendsAt.getTime() - paymentFailedAt.getTime(); + const elapsedFraction = (now - paymentFailedAt.getTime()) / windowMs; + const daysLeft = Math.max(0, Math.ceil((suspendsAt.getTime() - now) / DAY_MS)); + + return { + phase: elapsedFraction >= LOCK_AT_FRACTION ? 'locked' : 'warning', + daysLeft, + urgent: elapsedFraction >= URGENT_AT_FRACTION, + lockDismissed: lockDismissedFor === paymentFailedAt.toISOString(), + paymentFailedAt, + suspendsAt, + }; +} diff --git a/apps/admin/src/dunning/use-owner-user.ts b/apps/admin/src/dunning/use-owner-user.ts new file mode 100644 index 00000000000..3923dee6a50 --- /dev/null +++ b/apps/admin/src/dunning/use-owner-user.ts @@ -0,0 +1,22 @@ +import { isOwnerUser, useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; + +/** + * Resolves the site owner, for the staff-facing owner card on the locked + * takeover. + * + * Queries the Owner directly, so their details are available even when they + * fall beyond the first page of staff. Returns `undefined` while loading or + * when the current user's role cannot browse users (e.g. contributors) — callers + * degrade to copy without the owner's details. + * + * Pass `enabled: false` to skip the request entirely: the consuming + * components mount on every Admin page, so the user list must only be + * fetched in the rare sessions that actually show the owner. + */ +export function useOwnerUser({ enabled = true }: { enabled?: boolean } = {}): User | undefined { + const { data } = useBrowseUsers({ + enabled, + searchParams: { filter: "roles.name:'Owner'", limit: '1', include: 'roles' }, + }); + return data?.users.find(isOwnerUser); +} diff --git a/apps/admin/src/editor/browse-params.ts b/apps/admin/src/editor/browse-params.ts new file mode 100644 index 00000000000..d76bf0e4fb8 --- /dev/null +++ b/apps/admin/src/editor/browse-params.ts @@ -0,0 +1,13 @@ +/** + * Browse search params the editor reads from more than one place. A query's + * cache key is the serialized URL, so key order here is what shares the entry. + */ + +/** Every paid tier, archived ones included: the access, preview and publish tier pickers. */ +export const PAID_TIERS_SEARCH_PARAMS = { filter: 'type:paid', limit: 'all' } as const; + +/** + * Every newsletter, archived ones included. The preview narrows to active ones + * in the client rather than asking for a second, differently filtered list. + */ +export const NEWSLETTERS_SEARCH_PARAMS = { limit: 'all' } as const; diff --git a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx index 8554e14cd1b..37b7b57610e 100644 --- a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx @@ -345,6 +345,19 @@ describe('Post settings access', () => { await expect.element(editorScreen.updateButton()).toBeEnabled(); }); + it('browses paid tiers at the URL the publish flow and preview also send', async () => { + fakeSavablePost({ visibility: 'tiers', tiers: [{ id: GOLD.id }] }); + const tiersApi = fakeTiers(SITE_TIERS); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAccess(); + + await expect.element(editorScreen.settingsTier('Gold')).toBeVisible(); + // A differently spelled param order would be a second cache entry and a second browse. + await expect + .poll(() => new URL(tiersApi.lastRequest?.url ?? '', window.location.origin).search) + .toBe('?filter=type%3Apaid&limit=all'); + }); + it('leaves Access out for a role that cannot set it', async () => { fakeSavablePost({ authors: [{ id: '1' }] }); await renderAdminApp(`/editor/post/${POST_ID}`, asContributor()); diff --git a/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx index 9a9c90c73f3..86c0f7a4c15 100644 --- a/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-authors.acceptance.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { + browseResponse, currentUserResponse, fakeAdminEndpoint, fakeEditorChrome, @@ -388,6 +389,23 @@ describe('Post settings authors', () => { expect(editorScreen.settingsAuthorNames()).toEqual(['Owner User']); }); + it('offers every staff member, past the first page of the browse', async () => { + fakeSavablePost(); + // `limit=all` is capped by Core, so the site's staff can span several pages. + const staffApi = fakeAdminEndpoint('GET', /^\/users\/\?/, ({ url }) => { + const pageNumber = Number(new URL(url).searchParams.get('page') ?? '1'); + return browseResponse('users', [NADIA, JOSE], { page: pageNumber, limit: 1 }); + }); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openAuthors(); + await openAuthorList(); + + await expect.element(editorScreen.settingsAuthorOption(JOSE.name)).toBeVisible(); + await expect.element(editorScreen.settingsAuthorOption(NADIA.name)).toBeVisible(); + await expect.poll(() => staffApi.requests.length).toBe(2); + expect(new URL(staffApi.requests[1].url).searchParams.get('page')).toBe('2'); + }); + it.each(['Author', 'Contributor'] as StaffRoleName[])( 'leaves Authors out for a %s', async (role) => { diff --git a/apps/admin/src/editor/koenig-post-editor.test.tsx b/apps/admin/src/editor/koenig-post-editor.test.tsx new file mode 100644 index 00000000000..fd5816ccd2f --- /dev/null +++ b/apps/admin/src/editor/koenig-post-editor.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import type { PostCardConfig, PostType } from './card-config'; +import type { FeatureImageBinding } from './session/feature-image-binding'; +import { PostEditor } from './post-editor'; + +// Counted once per render of a composer subtree, which is how many times the +// hidden and the visible instance rebuild between them. +const composerRendered = vi.hoisted(() => vi.fn()); + +vi.mock('@/settings/components/koenig-loader', () => { + const stub = { + KoenigComposer: ({ children }: { children: ReactNode }) => { + composerRendered(); + return
{children}
; + }, + KoenigEditor: () => null, + WordCountPlugin: () => null, + TKCountPlugin: () => null, + }; + const resource = { read: () => stub }; + return { loadKoenig: () => resource }; +}); + +vi.mock('@tryghost/shade/app', async (importOriginal) => ({ + ...(await importOriginal>()), + useFocusContext: () => ({ darkMode: false }), +})); + +vi.mock('@tryghost/admin-x-framework/api/images', () => ({ + getImageUrl: () => '', + useUploadImage: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +const CARD_CONFIG = { siteUrl: 'https://example.com' } as unknown as PostCardConfig; + +const NOOP = () => {}; + +const FEATURE_IMAGE: FeatureImageBinding = { + featureImage: null, + featureImageAlt: null, + featureImageCaption: null, + onFeatureImageChange: NOOP, + onFeatureImageClear: NOOP, + onFeatureImageAltChange: NOOP, + onFeatureImageCaptionChange: NOOP, + onFeatureImageCaptionBlur: NOOP, +}; + +/** + * The editor surface as the screen mounts it, over a title the surface owns: + * typing into it re-renders everything the body's props are built in. + */ +function Harness({ postType }: { postType: PostType }) { + const [title, setTitle] = useState(''); + + return ( + + ); +} + +describe('KoenigPostEditor re-renders', () => { + it('does not rebuild its composers when the title is typed into', () => { + const { rerender } = render(); + const title = screen.getByTestId('editor-title-input'); + + expect(composerRendered).toHaveBeenCalledTimes(2); + + fireEvent.change(title, { target: { value: 'A' } }); + fireEvent.change(title, { target: { value: 'A t' } }); + + expect(composerRendered).toHaveBeenCalledTimes(2); + + // The count moves for a prop the body actually reads, so it is the memo + // holding it still rather than the probe missing renders. + rerender(); + + expect(composerRendered).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index f5b1d65d84c..598435d07ec 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -1,4 +1,4 @@ -import { Suspense, useCallback } from 'react'; +import { memo, Suspense, useCallback } from 'react'; import { LoadingIndicator } from '@tryghost/shade/components'; import { editorBody, editorSecondaryInstance } from '@tryghost/test-data/selectors/editor'; import ErrorBoundary from '@/settings/components/error-boundary'; @@ -83,7 +83,9 @@ function KoenigInstanceMount({ ); } -export function KoenigPostEditor(props: KoenigPostEditorProps) { +// Memoized: every prop is referentially stable, so a settings edit elsewhere in +// the editor must not re-render two composer subtrees. +export const KoenigPostEditor = memo(function KoenigPostEditor(props: KoenigPostEditorProps) { const editor = loadKoenig(); const { onSecondaryError } = props; @@ -121,4 +123,4 @@ export function KoenigPostEditor(props: KoenigPostEditorProps) { ); -} +}); diff --git a/apps/admin/src/editor/preview/README.md b/apps/admin/src/editor/preview/README.md index 0b17da081e9..ec814adbe94 100644 --- a/apps/admin/src/editor/preview/README.md +++ b/apps/admin/src/editor/preview/README.md @@ -33,6 +33,8 @@ The Email tab is offered for posts only, when members are on, newsletters are no The rendered email arrives as a complete HTML document and is shown in a `srcdoc` iframe sandboxed without `allow-scripts` and without `allow-same-origin`, so it can neither run its own scripts nor reach the admin page. Scrollbar styling is concatenated into that document because the admin stylesheet does not apply inside it. +The newsletters offered are the site's active ones, read from the same full browse the publish flow reads and narrowed here, every page of it. The post's own newsletter stays selectable even once it has been archived, which is looked up by slug; a newsletter the site has deleted leaves the email unsendable. + Switching newsletters re-renders the preview against that newsletter, and the test send goes to exactly one address — the current user's, unless it is edited — for the audience currently selected. ## Not here yet diff --git a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx index b18f82a1b08..c9958fb9463 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.component.test.tsx @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { page } from 'vitest/browser'; import { + browseResponse, configResponse, currentUserResponse, fakeAdminEndpoint, @@ -371,6 +372,37 @@ describe('Post preview modal', () => { await expect.poll(() => previewApi.lastRequest?.url).toContain('newsletter=monthly-roundup'); }); + it('offers every newsletter, past the first page of the browse', async () => { + fakeTiers([]); + const first = newsletter({ name: 'Weekly digest', slug: 'weekly-digest' }); + const second = newsletter({ name: 'Monthly roundup', slug: 'monthly-roundup' }); + // `limit=all` is capped by Core, so the site's newsletters can span several pages. + const newslettersApi = fakeAdminEndpoint('GET', /^\/newsletters\/\?/, ({ url }) => { + const params = new URL(url).searchParams; + if (params.get('filter')) { + return browseResponse('newsletters', [], { limit: 1 }); + } + + return browseResponse('newsletters', [first, second], { + page: Number(params.get('page') ?? '1'), + limit: 1, + }); + }); + const previewApi = fakeEmailPreview(); + await renderPreviewModal({ newsletterSlug: 'monthly-roundup' }); + + await previewScreen.emailTab().click(); + + await expect.element(previewScreen.newsletterSelect()).toHaveTextContent('Monthly roundup'); + await expect.poll(() => newslettersApi.requests.length).toBe(2); + expect(new URL(newslettersApi.requests[1].url).searchParams.get('page')).toBe('2'); + // A newsletter past the first page must not be taken for one that has been archived. + expect( + newslettersApi.requests.some((request) => new URL(request.url).searchParams.get('filter')), + ).toBe(false); + await expect.poll(() => previewApi.lastRequest?.url).toContain('newsletter=monthly-roundup'); + }); + it('preselects the post’s own newsletter', async () => { fakePreviewWorld({ newsletters: [ @@ -464,11 +496,11 @@ describe('Post preview modal', () => { await expect.poll(() => previewApi.requests.length).toBe(1); }); - it('reports and retries a failed active-newsletter lookup', async () => { + it('reports and retries a failed newsletter lookup', async () => { fakePreviewWorld(); const lookupApi = fakeAdminEndpoint( 'GET', - /^\/newsletters\/\?.*filter=status(?:%3A|:)active/, + /^\/newsletters\/\?limit=all/, { errors: [{ message: 'Could not load newsletters' }] }, { status: 500 }, ); diff --git a/apps/admin/src/editor/preview/post-preview-modal.tsx b/apps/admin/src/editor/preview/post-preview-modal.tsx index 2e575b51a5b..cdaceddf927 100644 --- a/apps/admin/src/editor/preview/post-preview-modal.tsx +++ b/apps/admin/src/editor/preview/post-preview-modal.tsx @@ -35,6 +35,7 @@ import { isOwnerUser, } from '@tryghost/admin-x-framework/api/users'; +import { NEWSLETTERS_SEARCH_PARAMS, PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { postPreviewModal, postPreviewSaveFailed } from '@tryghost/test-data/selectors/editor'; import { useEditorSettings } from '@/editor/use-editor-settings'; @@ -111,7 +112,7 @@ export function PostPreviewModal({ (isOwnerUser(currentUser) || isAdminUser(currentUser) || isEditorUser(currentUser)); const { data: tiersData } = useBrowseTiers({ - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, enabled: open && prepareState === 'ready' && paidMembersEnabled === true, requestOptions: EDITOR_REQUEST_OPTIONS, }); @@ -119,16 +120,39 @@ export function PostPreviewModal({ const { data: newslettersData, + fetchNextPage: fetchNextNewsletterPage, + hasNextPage: hasNextNewsletterPage, isError: activeNewslettersError, - isFetching: activeNewslettersFetching, + isFetching: newslettersFetching, + isFetchingNextPage: isFetchingNextNewsletterPage, refetch: refetchActiveNewsletters, } = useBrowseNewsletters({ - searchParams: { filter: 'status:active', limit: 'all' }, + searchParams: NEWSLETTERS_SEARCH_PARAMS, enabled: open && prepareState === 'ready' && emailAvailable, requestOptions: EDITOR_REQUEST_OPTIONS, - staleTime: 0, }); - const activeNewsletters = useMemo(() => newslettersData?.newsletters ?? [], [newslettersData]); + + // Core caps `limit=all`, so the response can still contain a next page. A + // newsletter past the cap would otherwise be taken for an archived one. + useEffect(() => { + if (hasNextNewsletterPage && !isFetchingNextNewsletterPage && !activeNewslettersError) { + void fetchNextNewsletterPage(); + } + }, [ + activeNewslettersError, + fetchNextNewsletterPage, + hasNextNewsletterPage, + isFetchingNextNewsletterPage, + ]); + const activeNewslettersFetching = + newslettersFetching || hasNextNewsletterPage || isFetchingNextNewsletterPage; + // The browse carries every newsletter, which is also the publish flow's list; + // narrowing here shares that one cache entry instead of asking for a subset. + const activeNewsletters = useMemo( + () => + (newslettersData?.newsletters ?? []).filter((newsletter) => newsletter.status === 'active'), + [newslettersData], + ); // The post's newsletter is what its email renders as, so it stays selectable // even once it has left the active list. diff --git a/apps/admin/src/editor/publish/components/email-recipients-options.tsx b/apps/admin/src/editor/publish/components/email-recipients-options.tsx index 04a1a640056..ceccb175e62 100644 --- a/apps/admin/src/editor/publish/components/email-recipients-options.tsx +++ b/apps/admin/src/editor/publish/components/email-recipients-options.tsx @@ -12,6 +12,7 @@ import { publishNewsletterSelect } from '@tryghost/test-data/selectors/editor'; import { useBrowseConfig } from '@tryghost/admin-x-framework/api/config'; import { useBrowseLabelsInfinite } from '@tryghost/admin-x-framework/api/labels'; import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers'; +import { PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { useEffect, useId, useMemo } from 'react'; @@ -46,7 +47,7 @@ export function EmailRecipientsOptions({ const tiersQuery = useBrowseTiers({ defaultErrorHandler: false, requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, }); const labelsQuery = useBrowseLabelsInfinite({ defaultErrorHandler: false, diff --git a/apps/admin/src/editor/publish/use-publish-inputs.ts b/apps/admin/src/editor/publish/use-publish-inputs.ts index fc4ccb03b98..75956c2d6c9 100644 --- a/apps/admin/src/editor/publish/use-publish-inputs.ts +++ b/apps/admin/src/editor/publish/use-publish-inputs.ts @@ -4,6 +4,7 @@ import { useCurrentUser } from '@tryghost/admin-x-framework/api/current-user'; import { useMembersCount } from '@tryghost/admin-x-framework/api/members'; import { useCallback, useEffect, useMemo } from 'react'; import { z } from 'zod'; +import { NEWSLETTERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { useEditorSettings, useSiteTimezone } from '@/editor/use-editor-settings'; import type { PublishSiteInput, PublishUserInput } from './publish-options'; @@ -153,7 +154,7 @@ export function usePublishInputs(): PublishInputs { const newslettersQuery = useBrowseNewsletters({ defaultErrorHandler: false, requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { limit: 'all' }, + searchParams: NEWSLETTERS_SEARCH_PARAMS, }); const { fetchNextPage: fetchNextNewsletterPage, diff --git a/apps/admin/src/editor/settings/README.md b/apps/admin/src/editor/settings/README.md index 69f919c9ed0..2c8beed5e17 100644 --- a/apps/admin/src/editor/settings/README.md +++ b/apps/admin/src/editor/settings/README.md @@ -3,7 +3,9 @@ `apps/admin/src/editor/settings/` holds the settings panel beside the post editor: the frame, its header toggle, and the sections that edit a post's non-body fields. Nothing here talks to the API. Every field goes through the -editing session, which is the only writer. +editing session, which is the only writer. A section is handed a narrow port +onto that session — the settings fields, their writers, and the few other +members the sections read — rather than the whole editing handle. ## Save policy @@ -261,7 +263,9 @@ page editor as well as the post editor. Users are never created here, so the lis offers people the site already has. It is read once, when the list is first opened, rather than on every editor entry, and the chips are named from the post's own relations until then — including the chips an edit leaves behind, so -removing one never leaves the rest reading as bare ids. +removing one never leaves the rest reading as bare ids. The read continues until +every page of staff has arrived, and the list reads as loading until it has, so +a site with more staff than one page holds still offers all of them. A failed staff lookup shows an error and a Retry action in the list. Retrying keeps the selected authors and returns focus to the search field. diff --git a/apps/admin/src/editor/settings/access-section.tsx b/apps/admin/src/editor/settings/access-section.tsx index 7b221b81856..7d5766b7b8e 100644 --- a/apps/admin/src/editor/settings/access-section.tsx +++ b/apps/admin/src/editor/settings/access-section.tsx @@ -18,10 +18,11 @@ import { settingsVisibilitySelect, } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; +import { PAID_TIERS_SEARCH_PARAMS } from '@/editor/browse-params'; import { useEditorSettings } from '@/editor/use-editor-settings'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { TIERS_REQUIRED, tiersIncomplete } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SectionLoadError } from './section-load-error'; import { SettingsSection } from './settings-section'; import { @@ -86,7 +87,7 @@ function TierGroup({ } export interface AccessSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } @@ -116,7 +117,7 @@ export function AccessSection({ session, postType }: AccessSectionProps) { defaultErrorHandler: false, enabled: visibility === 'tiers', requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: { filter: 'type:paid', limit: 'all' }, + searchParams: PAID_TIERS_SEARCH_PARAMS, }); const options = tierOptions(tiersData?.tiers); diff --git a/apps/admin/src/editor/settings/authors-section.tsx b/apps/admin/src/editor/settings/authors-section.tsx index 952657be8eb..9b40f75421b 100644 --- a/apps/admin/src/editor/settings/authors-section.tsx +++ b/apps/admin/src/editor/settings/authors-section.tsx @@ -1,17 +1,17 @@ -import { useCallback, useId, useState } from 'react'; +import { useCallback, useEffect, useId, useState } from 'react'; import { FieldError, Label } from '@tryghost/shade/components'; import { useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; import type { PostAuthor } from '@tryghost/admin-x-framework/api/posts'; import { settingsAuthorsError } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { AUTHORS_REQUIRED } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; import { AuthorsPicker } from './authors-picker'; import { AUTHORS_SEARCH_PARAMS, selectedAuthors, type AuthorOption } from './authors-options'; export interface AuthorsSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; currentUser?: User; } @@ -26,12 +26,21 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { const [browsing, setBrowsing] = useState(false); const startBrowsing = useCallback(() => setBrowsing(true), []); - const { data, isFetching, isError, refetch } = useBrowseUsers({ - defaultErrorHandler: false, - enabled: browsing, - requestOptions: EDITOR_REQUEST_OPTIONS, - searchParams: AUTHORS_SEARCH_PARAMS, - }); + const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, refetch } = + useBrowseUsers({ + defaultErrorHandler: false, + enabled: browsing, + requestOptions: EDITOR_REQUEST_OPTIONS, + searchParams: AUTHORS_SEARCH_PARAMS, + }); + + // Core caps `limit=all`, so the response can still contain a next page. The + // list is not complete, and so still loading, until every page has arrived. + useEffect(() => { + if (hasNextPage && !isFetchingNextPage && !isError) { + void fetchNextPage(); + } + }, [fetchNextPage, hasNextPage, isFetchingNextPage, isError]); const authors = session.settings.authors as ReadonlyArray; // A post this session created carries its author's identity alone, and the @@ -52,7 +61,7 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { inputId={inputId} invalid={invalid} loadError={isError} - loading={isFetching} + loading={isFetching || hasNextPage} selected={selected} staff={data?.users ?? []} onChange={change} diff --git a/apps/admin/src/editor/settings/code-injection-section.tsx b/apps/admin/src/editor/settings/code-injection-section.tsx index 42455a5a1c3..994dca306b2 100644 --- a/apps/admin/src/editor/settings/code-injection-section.tsx +++ b/apps/admin/src/editor/settings/code-injection-section.tsx @@ -1,7 +1,7 @@ import { CodeEditor } from '@tryghost/shade/components'; import { LucideIcon } from '@tryghost/shade/utils'; import type { PostType } from '@/editor/card-config'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSubview } from './settings-subview'; // A binding that returns true prevents the event's default, which the pane @@ -38,7 +38,7 @@ function EditorLabel({ text, helper }: { text: string; helper: string }) { } export interface CodeInjectionSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/delete-section.tsx b/apps/admin/src/editor/settings/delete-section.tsx index d246c389bb8..6ea5a54408c 100644 --- a/apps/admin/src/editor/settings/delete-section.tsx +++ b/apps/admin/src/editor/settings/delete-section.tsx @@ -24,11 +24,11 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { DEFAULT_TITLE } from '@/editor/engine/save-engine'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; export interface DeleteSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/editor-settings-port.test.tsx b/apps/admin/src/editor/settings/editor-settings-port.test.tsx new file mode 100644 index 00000000000..cc5b491e944 --- /dev/null +++ b/apps/admin/src/editor/settings/editor-settings-port.test.tsx @@ -0,0 +1,109 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { body, record } from '@/editor/session/__test-utils__/session-harness'; +import { useEditorSession } from '@/editor/session/use-editor-session'; +import { useEditorSettingsPort } from './editor-settings-port'; + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: () => ({ key: 'editor', state: null }), +})); + +// The real hooks hand back one stable function per mount; a fresh mock per +// render would make the handle churn for a reason the hook does not own. +const stable = vi.hoisted(() => ({ fetchApi: vi.fn(), generateSlug: vi.fn() })); + +vi.mock('@tryghost/admin-x-framework/hooks', () => ({ + useFetchApi: () => stable.fetchApi, +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: () => ({ data: undefined }), +})); + +vi.mock('@tryghost/admin-x-framework/api/slugs', () => ({ + useGenerateSlug: () => stable.generateSlug, +})); + +vi.mock('@tryghost/admin-x-framework/api/posts', () => ({ + useAddPost: () => ({ mutateAsync: vi.fn() }), + useEditPost: () => ({ mutateAsync: vi.fn() }), + useEditorPost: () => ({ data: undefined }), + postsDataType: 'PostsResponseType', +})); + +vi.mock('@tryghost/admin-x-framework/api/pages', () => ({ + useAddPage: () => ({ mutateAsync: vi.fn() }), + useEditPage: () => ({ mutateAsync: vi.fn() }), + useEditorPage: () => ({ data: undefined }), + pagesDataType: 'PagesResponseType', +})); + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function setup() { + return renderHook( + () => { + const session = useEditorSession({ + postType: 'post', + record: record(), + siteUrl: 'https://example.com', + }); + return { session, port: useEditorSettingsPort(session) }; + }, + { wrapper: Wrapper }, + ); +} + +/** + * The port is what the settings sections memoize on, so it has to move with the + * members it carries rather than with everything the session publishes. + */ +describe('useEditorSettingsPort', () => { + it('keeps the same port across a render that changed nothing', () => { + const { result, rerender } = setup(); + const { port } = result.current; + + rerender(); + + expect(result.current.port).toBe(port); + }); + + it('keeps the same port when a body edit moves the handle', () => { + const { result } = setup(); + act(() => result.current.session.bind.onSecondaryChange(body('Hello'))); + + const { session, port } = result.current; + act(() => result.current.session.bind.onLexicalChange(body('Hello again'))); + + expect(result.current.session).not.toBe(session); + expect(result.current.session.isDirty()).toBe(true); + expect(result.current.port).toBe(port); + }); + + it('replaces the port but not its binding when a settings field changes', () => { + const { result } = setup(); + const { port } = result.current; + + act(() => result.current.port.stageSettings({ meta_title: 'A meta title' })); + + expect(result.current.port).not.toBe(port); + expect(result.current.port.settings.meta_title).toBe('A meta title'); + expect(result.current.port.bind).toBe(port.bind); + }); + + it('replaces the port when the title changes, which the sections read', () => { + const { result } = setup(); + const { port } = result.current; + + act(() => result.current.session.bind.onTitleChange('A new title')); + + expect(result.current.port).not.toBe(port); + expect(result.current.port.bind.title).toBe('A new title'); + }); +}); diff --git a/apps/admin/src/editor/settings/editor-settings-port.ts b/apps/admin/src/editor/settings/editor-settings-port.ts new file mode 100644 index 00000000000..3a014a286b7 --- /dev/null +++ b/apps/admin/src/editor/settings/editor-settings-port.ts @@ -0,0 +1,81 @@ +import { useMemo } from 'react'; +import type { + EditorSessionBinding, + EditorSessionHandle, +} from '@/editor/session/use-editor-session'; + +/** What the settings panel may reach on the editing session, and nothing else. */ +export type EditorSettingsPort = Pick< + EditorSessionHandle, + | 'commitSettings' + | 'createdId' + | 'dispose' + | 'editPublishedAt' + | 'editSettings' + | 'editSlug' + | 'loadedRecord' + | 'publishTime' + | 'restoreRevision' + | 'settings' + | 'slug' + | 'stageSettings' +> & { + bind: Pick; +}; + +/** The port's identity tracks the members it carries, not the render that produced it. */ +export function useEditorSettingsPort(session: EditorSessionHandle): EditorSettingsPort { + const { title, excerpt, onExcerptChange } = session.bind; + const { + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + } = session; + + const bind = useMemo( + () => ({ title, excerpt, onExcerptChange }), + [title, excerpt, onExcerptChange], + ); + + return useMemo( + () => ({ + bind, + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + }), + [ + bind, + commitSettings, + createdId, + dispose, + editPublishedAt, + editSettings, + editSlug, + loadedRecord, + publishTime, + restoreRevision, + settings, + slug, + stageSettings, + ], + ); +} diff --git a/apps/admin/src/editor/settings/meta-data-section.tsx b/apps/admin/src/editor/settings/meta-data-section.tsx index f0d12f4aa39..5d8fe1e5554 100644 --- a/apps/admin/src/editor/settings/meta-data-section.tsx +++ b/apps/admin/src/editor/settings/meta-data-section.tsx @@ -7,7 +7,7 @@ import { settingsMetaTitleInput, settingsSerpPreview, } from '@tryghost/test-data/selectors/editor'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { META_DESCRIPTION_RECOMMENDED, META_TITLE_RECOMMENDED, @@ -67,7 +67,7 @@ function SearchPreview({ } export interface MetaDataSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; siteUrl: string; } diff --git a/apps/admin/src/editor/settings/post-history-section.tsx b/apps/admin/src/editor/settings/post-history-section.tsx index 5aba278ba62..d9df38d3fd8 100644 --- a/apps/admin/src/editor/settings/post-history-section.tsx +++ b/apps/admin/src/editor/settings/post-history-section.tsx @@ -4,16 +4,19 @@ import { LucideIcon } from '@tryghost/shade/utils'; import { useFocusContext } from '@tryghost/shade/app'; import { settingsPostHistoryButton } from '@tryghost/test-data/selectors/editor'; import type { PostCardConfig, PostType } from '@/editor/card-config'; +import type { SaveEngineState } from '@/editor/engine/save-engine'; import { useSiteTimezone } from '@/editor/use-editor-settings'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { canViewPostHistory, revisionEntries, type RevisionEntry } from './post-history'; import { PostHistoryModal } from './post-history-modal'; import { SettingsSection } from './settings-section'; export interface PostHistorySectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; cardConfig: PostCardConfig; + /** The save engine's state, which says whether a restore can be written at all. */ + state: SaveEngineState; /** The excerpt has its own home under the title, and is restored with the version. */ showExcerpt: boolean; } @@ -28,6 +31,7 @@ export function PostHistorySection({ postType, cardConfig, showExcerpt, + state, }: PostHistorySectionProps) { const [open, setOpen] = useState(false); const triggerRef = useRef(null); @@ -82,8 +86,8 @@ export function PostHistorySection({ open={open} postType={postType} restoreError={ - (session.state.kind === 'error' && session.state.error.kind === 'session-invalid') || - session.state.kind === 'reauth-pending' + (state.kind === 'error' && state.error.kind === 'session-invalid') || + state.kind === 'reauth-pending' ? 'Your session expired. Sign in again in a new tab, then try restoring again.' : undefined } diff --git a/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx b/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx new file mode 100644 index 00000000000..5bdeb7f3320 --- /dev/null +++ b/apps/admin/src/editor/settings/post-settings-sidebar.test.tsx @@ -0,0 +1,64 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { PostCardConfig } from '@/editor/card-config'; +import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import { PostSettingsSidebar } from './post-settings-sidebar'; + +const mocks = vi.hoisted(() => ({ + port: {}, + urlSection: vi.fn(() => null), +})); + +vi.mock('./editor-settings-port', () => ({ + useEditorSettingsPort: () => mocks.port, +})); + +vi.mock('./access-section', () => ({ AccessSection: () => null })); +vi.mock('./authors-section', () => ({ AuthorsSection: () => null })); +vi.mock('./code-injection-section', () => ({ CodeInjectionSection: () => null })); +vi.mock('./delete-section', () => ({ DeleteSection: () => null })); +vi.mock('./keyboard-shortcuts-section', () => ({ KeyboardShortcutsSection: () => null })); +vi.mock('./meta-data-section', () => ({ MetaDataSection: () => null })); +vi.mock('./post-history-section', () => ({ PostHistorySection: () => null })); +vi.mock('./publish-date-section', () => ({ PublishDateSection: () => null })); +vi.mock('./show-title-section', () => ({ ShowTitleSection: () => null })); +vi.mock('./social-card-section', () => ({ SocialCardSection: () => null })); +vi.mock('./tags-section', () => ({ TagsSection: () => null })); +vi.mock('./template-section', () => ({ TemplateSection: () => null })); +vi.mock('./url-section', () => ({ UrlSection: mocks.urlSection })); + +const CARD_CONFIG = {} as PostCardConfig; + +function handle(kind: 'idle' | 'debouncing') { + return { state: { kind } } as EditorSessionHandle; +} + +function Sidebar({ session, siteUrl }: { session: EditorSessionHandle; siteUrl: string }) { + return ( + + ); +} + +describe('PostSettingsSidebar renders', () => { + it('does not rebuild an unaffected section when only save state changes', () => { + const { rerender } = render(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(1); + + rerender(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(1); + + // A prop the URL section reads still moves it, so the probe catches renders. + rerender(); + + expect(mocks.urlSection).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/admin/src/editor/settings/post-settings-sidebar.tsx b/apps/admin/src/editor/settings/post-settings-sidebar.tsx index 5b7b406b79e..edcb2de8f05 100644 --- a/apps/admin/src/editor/settings/post-settings-sidebar.tsx +++ b/apps/admin/src/editor/settings/post-settings-sidebar.tsx @@ -1,4 +1,4 @@ -import { Fragment, type ReactNode, useEffect, useId } from 'react'; +import { Fragment, memo, type ReactNode, useEffect, useId } from 'react'; import { Label, Separator, Switch, Textarea } from '@tryghost/shade/components'; import { Inline, Text } from '@tryghost/shade/primitives'; import { cn } from '@tryghost/shade/utils'; @@ -20,6 +20,7 @@ import { PublishDateSection } from './publish-date-section'; import { AuthorsSection } from './authors-section'; import { CodeInjectionSection } from './code-injection-section'; import { DeleteSection } from './delete-section'; +import { type EditorSettingsPort, useEditorSettingsPort } from './editor-settings-port'; import { KeyboardShortcutsSection } from './keyboard-shortcuts-section'; import { MetaDataSection } from './meta-data-section'; import { PostHistorySection } from './post-history-section'; @@ -33,7 +34,21 @@ import { TagsSection } from './tags-section'; import { TemplateSection } from './template-section'; import { UrlSection } from './url-section'; -function ExcerptSection({ session }: { session: EditorSessionHandle }) { +const MemoAccessSection = memo(AccessSection); +const MemoAuthorsSection = memo(AuthorsSection); +const MemoCodeInjectionSection = memo(CodeInjectionSection); +const MemoDeleteSection = memo(DeleteSection); +const MemoKeyboardShortcutsSection = memo(KeyboardShortcutsSection); +const MemoMetaDataSection = memo(MetaDataSection); +const MemoPostHistorySection = memo(PostHistorySection); +const MemoPublishDateSection = memo(PublishDateSection); +const MemoShowTitleSection = memo(ShowTitleSection); +const MemoSocialCardSection = memo(SocialCardSection); +const MemoTagsSection = memo(TagsSection); +const MemoTemplateSection = memo(TemplateSection); +const MemoUrlSection = memo(UrlSection); + +const ExcerptSection = memo(function ExcerptSection({ session }: { session: EditorSettingsPort }) { const inputId = useId(); return ( @@ -49,13 +64,13 @@ function ExcerptSection({ session }: { session: EditorSessionHandle }) { /> ); -} +}); -function FeaturedSection({ +const FeaturedSection = memo(function FeaturedSection({ session, postType, }: { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; }) { const inputId = useId(); @@ -73,7 +88,7 @@ function FeaturedSection({ ); -} +}); export interface PostSettingsSidebarProps { session: EditorSessionHandle; @@ -94,7 +109,7 @@ export interface PostSettingsSidebarProps { * through the session, which owns when it is persisted (see the README). */ export function PostSettingsSidebar({ - session, + session: handle, postType, siteUrl, cardConfig, @@ -102,6 +117,9 @@ export function PostSettingsSidebar({ currentUser, hasInlineExcerpt = false, }: PostSettingsSidebarProps) { + // The sections take the narrow port rather than the handle, so an edit they + // cannot see does not hand them a new object. + const session = useEditorSettingsPort(handle); // Owner, Administrator and Editor manage featured and access. const canManagePost = !!currentUser && canAccessSettings(currentUser); const canTag = !!currentUser && !isContributorUser(currentUser); @@ -110,24 +128,26 @@ export function PostSettingsSidebar({ const subviews = useSubviewController(); const sections: Record = { - url: , - 'publish-date': , - tags: canTag ? : null, + url: , + 'publish-date': , + tags: canTag ? : null, excerpt: hasInlineExcerpt ? null : , featured: canManagePost ? : null, - access: canManagePost ? : null, + access: canManagePost ? : null, authors: canCreditOthers ? ( - + ) : null, 'show-title-and-feature-image': - postType === 'page' ? : null, - template: , - delete: , - 'code-injection': , - 'meta-data': , - 'keyboard-shortcuts': , + postType === 'page' ? ( + + ) : null, + template: , + delete: , + 'code-injection': , + 'meta-data': , + 'keyboard-shortcuts': , 'x-card': ( - ), 'facebook-card': ( - ), 'post-history': ( - ), }; diff --git a/apps/admin/src/editor/settings/publish-date-section.tsx b/apps/admin/src/editor/settings/publish-date-section.tsx index c9c9eeeb994..0bfd3588199 100644 --- a/apps/admin/src/editor/settings/publish-date-section.tsx +++ b/apps/admin/src/editor/settings/publish-date-section.tsx @@ -10,14 +10,14 @@ import { import { DateTimePicker } from '@/editor/date-time-picker'; import { useSiteTimezone } from '@/editor/use-editor-settings'; import { PUBLISHED_AT_MUST_BE_PAST, publishedAtInFuture } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; /** A scheduled post is re-timed from the publish menu, not from here. */ const RESCHEDULE_NOTE = 'Use the publish menu to re-schedule'; export interface PublishDateSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; } /** diff --git a/apps/admin/src/editor/settings/show-title-section.tsx b/apps/admin/src/editor/settings/show-title-section.tsx index f8950e69b29..097fa69ca1e 100644 --- a/apps/admin/src/editor/settings/show-title-section.tsx +++ b/apps/admin/src/editor/settings/show-title-section.tsx @@ -8,7 +8,7 @@ import { settingsShowTitleWarning, } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; const PAGE_BUILDER_ATTRIBUTE = 'show_title_and_feature_image'; @@ -16,7 +16,7 @@ const THEME_WARNING = "Uh-oh. Looks like your theme doesn't support this feature const THEME_DOCS_URL = 'https://docs.ghost.org/themes/helpers/'; export interface ShowTitleSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; currentUser?: User; } diff --git a/apps/admin/src/editor/settings/social-card-section.tsx b/apps/admin/src/editor/settings/social-card-section.tsx index b229e6fdd53..65ce33e2435 100644 --- a/apps/admin/src/editor/settings/social-card-section.tsx +++ b/apps/admin/src/editor/settings/social-card-section.tsx @@ -4,8 +4,8 @@ import { Stack, Text } from '@tryghost/shade/primitives'; import BrandIcon from '@/shared/brand-icon/brand-icon'; import type { PostCardConfig } from '@/editor/card-config'; import { ImageField } from '@/editor/image-field'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { useImageFieldUpload } from '@/editor/use-image-field-upload'; +import type { EditorSettingsPort } from './editor-settings-port'; import { truncate } from './meta-data-fields'; import { SettingsSubview } from './settings-subview'; import { @@ -23,7 +23,7 @@ import { useSettingsField } from './use-settings-field'; export interface SocialCardSectionProps { /** Which network's card this pane edits (see `social-card-networks.ts`). */ network: SocialCardNetwork; - session: EditorSessionHandle; + session: EditorSettingsPort; /** The site's homepage URL, which the card previews the post under. */ siteUrl: string; /** The feature image the writer is looking at, which the card falls back to. */ diff --git a/apps/admin/src/editor/settings/tags-section.tsx b/apps/admin/src/editor/settings/tags-section.tsx index 7e7280ff17d..e9075292bbc 100644 --- a/apps/admin/src/editor/settings/tags-section.tsx +++ b/apps/admin/src/editor/settings/tags-section.tsx @@ -7,7 +7,7 @@ import { settingsTagsToken, } from '@tryghost/test-data/selectors/editor'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { TagPicker } from '@/shared/tags/tag-picker'; import { addTag, removeTag, type TagLike } from '@/shared/tags/tag-selection'; import { SettingsSection } from './settings-section'; @@ -19,7 +19,7 @@ const TAG_NAME_MAX_LENGTH = 191; * The post's tags in order, which is the `sort_order` Ghost stores. The field * holds the records the chips are drawn from; the save writes identities. */ -export function TagsSection({ session }: { session: EditorSessionHandle }) { +export function TagsSection({ session }: { session: EditorSettingsPort }) { const inputId = useId(); const tags = session.settings.tags; diff --git a/apps/admin/src/editor/settings/template-section.tsx b/apps/admin/src/editor/settings/template-section.tsx index 3990e80b0e4..faf9bd6f0ab 100644 --- a/apps/admin/src/editor/settings/template-section.tsx +++ b/apps/admin/src/editor/settings/template-section.tsx @@ -15,7 +15,7 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SectionLoadError } from './section-load-error'; import { SettingsSection } from './settings-section'; import { @@ -28,7 +28,7 @@ import { } from './template-options'; export interface TemplateSectionProps { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; } diff --git a/apps/admin/src/editor/settings/url-section.tsx b/apps/admin/src/editor/settings/url-section.tsx index 7daf62cd2de..ccac33e15c5 100644 --- a/apps/admin/src/editor/settings/url-section.tsx +++ b/apps/admin/src/editor/settings/url-section.tsx @@ -8,7 +8,7 @@ import { } from '@tryghost/test-data/selectors/editor'; import type { PostType } from '@/editor/card-config'; import { normalizeManualSlug } from '@/editor/engine/slug-machine'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { SettingsSection } from './settings-section'; import { formatUrlPreview } from './url-preview'; @@ -23,7 +23,7 @@ export function UrlSection({ postType, siteUrl, }: { - session: EditorSessionHandle; + session: EditorSettingsPort; postType: PostType; siteUrl: string; }) { diff --git a/apps/admin/src/editor/settings/use-settings-field.test.ts b/apps/admin/src/editor/settings/use-settings-field.test.ts index 7698f7d905a..78ab09b7f9b 100644 --- a/apps/admin/src/editor/settings/use-settings-field.test.ts +++ b/apps/admin/src/editor/settings/use-settings-field.test.ts @@ -7,7 +7,7 @@ import { OG_TITLE_MAX, type ValidatedSettingsFields, } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; import { useSettingsField } from './use-settings-field'; const SETTINGS: ValidatedSettingsFields = { @@ -26,7 +26,7 @@ function fakeSession(settings: Partial = {}) { settings: { ...SETTINGS, ...settings }, stageSettings: vi.fn(), commitSettings: vi.fn(), - } as unknown as EditorSessionHandle & { + } as unknown as EditorSettingsPort & { stageSettings: ReturnType; commitSettings: ReturnType; }; diff --git a/apps/admin/src/editor/settings/use-settings-field.ts b/apps/admin/src/editor/settings/use-settings-field.ts index 05b4f3cd816..81fe710ba2c 100644 --- a/apps/admin/src/editor/settings/use-settings-field.ts +++ b/apps/admin/src/editor/settings/use-settings-field.ts @@ -3,7 +3,7 @@ import { settingsFieldErrorFor, type ValidatedSettingsFieldKey, } from '@/editor/session/settings-fields'; -import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; +import type { EditorSettingsPort } from './editor-settings-port'; /** The settings keys a plain text field writes: the ones held to a length. */ export type SettingsTextFieldKey = Exclude; @@ -30,7 +30,7 @@ export interface SettingsFieldBinding { * is a hint's id, which the field points at alongside any error. */ export function useSettingsField( - session: EditorSessionHandle, + session: EditorSettingsPort, key: SettingsTextFieldKey, describedBy?: string, ): SettingsFieldBinding { diff --git a/apps/admin/src/layout/admin-layout.tsx b/apps/admin/src/layout/admin-layout.tsx index 3b828904561..b44ebf11ea4 100644 --- a/apps/admin/src/layout/admin-layout.tsx +++ b/apps/admin/src/layout/admin-layout.tsx @@ -8,6 +8,7 @@ import { cn } from '@tryghost/shade/utils'; import AppSidebar from './app-sidebar'; import { MobileNavBar } from './app-sidebar/mobile-nav-bar'; import { ContributorUserMenu } from './app-sidebar/user-menu'; +import { DunningBanner, DunningOverlay, useDunningLockTakeover } from '@/dunning'; const networkPageChrome = { contentClassName: 'max-w-(--content-width)', @@ -52,18 +53,54 @@ interface AdminLayoutProps { export function AdminLayout({ children }: AdminLayoutProps) { const { data: currentUser } = useCurrentUser(); const sidebarVisible = useAdminSidebarVisibility(); + const dunningLocked = useDunningLockTakeover(); const isContributor = currentUser && isContributorUser(currentUser); + // The dunning takeover is positioned against the scrollable inset, so the + // inset must not scroll (and must sit at the top) while the takeover is up — + // otherwise the covered page scrolls back into view from underneath it + const insetRef = React.useRef(null); + React.useEffect(() => { + if (dunningLocked) { + insetRef.current?.scrollTo?.(0, 0); + } + }, [dunningLocked]); + + // The covered regions become `inert` while the takeover is up: aria-modal is + // only a semantic hint, so without this the covered page stays reachable by + // keyboard and assistive technology. Applied through refs because React 18 + // has no first-class inert prop. Whichever refs the active layout branch + // doesn't render stay null and are skipped. + // + // A layout effect on purpose: layout effects run before passive-effect + // cleanups, so on dismissal inert is cleared before the overlay's cleanup + // restores focus — focus() on a still-inert element is a silent no-op. + const sidebarRef = React.useRef(null); + const mainRef = React.useRef(null); + const contributorMenuRef = React.useRef(null); + React.useLayoutEffect(() => { + for (const region of [sidebarRef.current, mainRef.current, contributorMenuRef.current]) { + if (region) { + region.inert = dunningLocked; + } + } + }, [dunningLocked]); + // Contributors get a floating profile menu instead of the full sidebar if (isContributor) { return (
-
+
+
{children}
-
+
+
); } @@ -77,16 +114,32 @@ export function AdminLayout({ children }: AdminLayoutProps) { open={!!currentUser && sidebarVisible} style={sidebarVisible ? ({ '--sidebar-width': '316px' } as React.CSSProperties) : undefined} > - {sidebarVisible && } + {sidebarVisible && ( + + )} -
+ +
{children}
- + {/* The mobile nav sits outside the takeover's cover (fixed, above the + inset) and its sheet opens in a portal, so it unmounts entirely + rather than relying on inert */} + {!dunningLocked && } + ); diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar.tsx index 837935a1947..061539eba5d 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar.tsx @@ -6,14 +6,16 @@ import AppSidebarHeader from './app-sidebar-header'; import AppSidebarFooter from './app-sidebar-footer'; import AppSidebarContent from './app-sidebar-content'; -function AppSidebar({ ...props }: React.ComponentProps) { - return ( - - - - - - ); -} +const AppSidebar = React.forwardRef>( + function AppSidebar({ ...props }, ref) { + return ( + + + + + + ); + }, +); export default AppSidebar; diff --git a/apps/admin/src/members/activity/activity-filters.test.ts b/apps/admin/src/members/activity/activity-filters.test.ts index 0d3f1ac057f..5187a526d61 100644 --- a/apps/admin/src/members/activity/activity-filters.test.ts +++ b/apps/admin/src/members/activity/activity-filters.test.ts @@ -14,7 +14,7 @@ describe('activity filters', () => { expect(global).not.toContain('automated_email_sent_event'); expect(activityQueryOptions({ settings: {}, excluded: null, memberId: 'abc' })).toEqual({ memberId: 'abc', - excludedEvents: ['aggregated_click_event'], + excludedEvents: ['aggregated_click_event', 'metafield_change_event'], }); expect(availableActivityTypes({}, 'abc').map(({ event }) => event)).toContain( 'email_opened_event', @@ -55,6 +55,23 @@ describe('activity filters', () => { ); }); + it('offers custom field changes only where the site has custom fields', () => { + const events = (settings: Parameters[0]) => + availableActivityTypes(settings, 'abc').map(({ event }) => event); + expect(events({ customFieldsAvailable: true })).toContain('metafield_change_event'); + expect(events({})).not.toContain('metafield_change_event'); + expect( + activityQueryOptions({ settings: {}, excluded: null, memberId: 'abc' }).excludedEvents, + ).toContain('metafield_change_event'); + expect( + activityQueryOptions({ + settings: { customFieldsAvailable: true }, + excluded: null, + memberId: 'abc', + }).excludedEvents, + ).not.toContain('metafield_change_event'); + }); + it.each([ ['subscription_event', 'gift_redemption_event', 'gift_ended_event'], ['payment_event', 'donation_event', 'gift_purchase_event'], diff --git a/apps/admin/src/members/activity/activity-filters.ts b/apps/admin/src/members/activity/activity-filters.ts index 195dd578a1a..a44b2583b1d 100644 --- a/apps/admin/src/members/activity/activity-filters.ts +++ b/apps/admin/src/members/activity/activity-filters.ts @@ -2,6 +2,7 @@ export interface ActivitySettings { editorDefaultEmailRecipients?: string; commentsEnabled?: string; emailTrackClicks?: boolean; + customFieldsAvailable?: boolean; } const EMAIL_EVENTS = [ @@ -64,6 +65,12 @@ const EVENT_TYPES = [ group: 'emails', icon: 'event-sent-email', }, + { + event: 'metafield_change_event', + name: 'Custom fields updated', + group: 'others', + icon: 'event-metafields-changed', + }, { event: 'feedback_event', name: 'Feedback', group: 'others', icon: 'event-more-like-this' }, ]; @@ -81,6 +88,9 @@ function hiddenActivityEvents(settings: ActivitySettings, memberId?: string): st if (settings.editorDefaultEmailRecipients === 'disabled') { hidden.push('newsletter_event'); } + if (!settings.customFieldsAvailable) { + hidden.push('metafield_change_event'); + } return hidden; } diff --git a/apps/admin/src/members/activity/member-activity.acceptance.test.tsx b/apps/admin/src/members/activity/member-activity.acceptance.test.tsx index 5da03cc46ef..73461de3af8 100644 --- a/apps/admin/src/members/activity/member-activity.acceptance.test.tsx +++ b/apps/admin/src/members/activity/member-activity.acceptance.test.tsx @@ -169,6 +169,30 @@ describe('Member activity', () => { .toContain('comment_event'); }); + it("asks for the same events on a member's page as on their full activity page", async () => { + const { eventsApi } = world([]); + fakeAdminEndpoint('GET', /^\/members\/ada\/\?include=tiers/, { members: [ada] }); + const settings = { + browseSettings: { + response: settingsResponse({ settings: { comments_enabled: 'off' } }), + }, + }; + const excludedTypes = () => + new URL(eventsApi.lastRequest!.url).searchParams + .get('filter') + ?.match(/type:-\[([^\]]*)\]/)?.[1] + ?.replaceAll("'", ''); + + await renderAdminApp('/members/ada', { labs, boot: settings }); + await expect.poll(excludedTypes).toBeDefined(); + const preview = excludedTypes()!.split(',').sort(); + expect(preview).toContain('comment_event'); + expect(preview).toContain('metafield_change_event'); + + await renderAdminApp('/members-activity?member=ada', { labs, boot: settings }); + await expect.poll(() => excludedTypes()?.split(',').sort()).toEqual(preview); + }); + it('renders absent members and unknown event types without unsafe links', async () => { const unknown = event('future', 'future_event', null); const signup = event('unsafe'); diff --git a/apps/admin/src/members/activity/member-activity.tsx b/apps/admin/src/members/activity/member-activity.tsx index 84bdb0238c7..6bef07dad73 100644 --- a/apps/admin/src/members/activity/member-activity.tsx +++ b/apps/admin/src/members/activity/member-activity.tsx @@ -30,13 +30,14 @@ import { APIError } from '@tryghost/admin-x-framework/errors'; import { useBrowseMemberActivityFeed, useMember } from '@tryghost/admin-x-framework/api/members'; import { useBrowseNewsletters } from '@tryghost/admin-x-framework/api/newsletters'; import { useBrowseTiers } from '@tryghost/admin-x-framework/api/tiers'; -import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { getSettingValue } from '@tryghost/admin-x-framework/api/settings'; import { formatMemberName, memberAvatarProps } from '@/members/member-format'; import { parseActivityEvent } from './activity-event'; import ActivityEmailPreview from './activity-email-preview'; import ActivityMemberSearch from './activity-member-search'; import ActivityRow from './activity-row'; import { EventIcon } from '@/members/detail/member-activity-feed'; +import { useActivitySettings } from './use-activity-settings'; import { availableActivityTypes, activityQueryOptions, @@ -50,14 +51,7 @@ function ActivityPage() { const excluded = params.get('excludedEvents'); const [previewEmail, setPreviewEmail] = useState(null); const sentinel = useRef(null); - const settingsQuery = useBrowseSettings({ defaultErrorHandler: false }); - const settings = settingsQuery.data?.settings ?? []; - const activitySettings = { - editorDefaultEmailRecipients: - getSettingValue(settings, 'editor_default_email_recipients') ?? undefined, - commentsEnabled: getSettingValue(settings, 'comments_enabled') ?? undefined, - emailTrackClicks: getSettingValue(settings, 'email_track_clicks') ?? undefined, - }; + const { settingsQuery, settings, activitySettings } = useActivitySettings(); const paidMembersEnabled = getSettingValue(settings, 'paid_members_enabled') ?? false; const memberQuery = useMember(memberId ?? '', { enabled: !!memberId, diff --git a/apps/admin/src/members/activity/use-activity-settings.ts b/apps/admin/src/members/activity/use-activity-settings.ts new file mode 100644 index 00000000000..536f34fb378 --- /dev/null +++ b/apps/admin/src/members/activity/use-activity-settings.ts @@ -0,0 +1,22 @@ +import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; +import { useCustomFieldsAvailable } from '@/shared/member-custom-fields/use-availability'; +import type { ActivitySettings } from './activity-filters'; + +/** + * What decides which events a site's activity shows. One hook for every screen that + * lists activity, so a member's page and their full activity page cannot disagree about + * which events exist. + */ +export function useActivitySettings() { + const settingsQuery = useBrowseSettings({ defaultErrorHandler: false }); + const settings = settingsQuery.data?.settings ?? []; + const customFieldsAvailable = useCustomFieldsAvailable(); + const activitySettings: ActivitySettings = { + editorDefaultEmailRecipients: + getSettingValue(settings, 'editor_default_email_recipients') ?? undefined, + commentsEnabled: getSettingValue(settings, 'comments_enabled') ?? undefined, + emailTrackClicks: getSettingValue(settings, 'email_track_clicks') ?? undefined, + customFieldsAvailable, + }; + return { settingsQuery, settings, activitySettings }; +} diff --git a/apps/admin/src/members/components/members-actions.tsx b/apps/admin/src/members/components/members-actions.tsx index ccd271ebdf9..debad31706b 100644 --- a/apps/admin/src/members/components/members-actions.tsx +++ b/apps/admin/src/members/components/members-actions.tsx @@ -26,6 +26,7 @@ import { useLocation, useNavigate } from '@tryghost/admin-x-framework'; import { exportMembers } from './members-actions-helpers'; interface MembersActionsProps { + children?: React.ReactNode; hasFilterOrSearch: boolean; memberCount: number; nql?: string; @@ -37,6 +38,7 @@ interface MembersActionsProps { } const MembersActions: React.FC = ({ + children, hasFilterOrSearch, memberCount, nql, @@ -304,6 +306,8 @@ const MembersActions: React.FC = ({ )} + {children} + {showNewMember && ( diff --git a/apps/admin/src/members/components/members-header-search.tsx b/apps/admin/src/members/components/members-header-search.tsx index 221b067de8a..83ad825a63c 100644 --- a/apps/admin/src/members/components/members-header-search.tsx +++ b/apps/admin/src/members/components/members-header-search.tsx @@ -20,7 +20,7 @@ const MembersHeaderSearch: React.FC = ({ collapsible = false, ariaLabel = 'Search members', }) => { - const { isAdmin7 } = useShade(); + const { controlShape, isAdmin7 } = useShade(); const [expanded, setExpanded] = useState(false); const restoreTriggerFocus = useRef(false); const isExpanded = !collapsible || expanded || search.length > 0; @@ -33,6 +33,7 @@ const MembersHeaderSearch: React.FC = ({ 'h-(--control-height) min-w-0', collapsible ? 'w-full' : 'basis-full lg:w-[180px] lg:basis-auto xl:w-[240px]', )} + shape={controlShape} variant={isAdmin7 ? 'secondary' : 'default'} > diff --git a/apps/admin/src/members/detail/member-activity-feed.tsx b/apps/admin/src/members/detail/member-activity-feed.tsx index 6f106d81ce8..ac43deabbb9 100644 --- a/apps/admin/src/members/detail/member-activity-feed.tsx +++ b/apps/admin/src/members/detail/member-activity-feed.tsx @@ -6,6 +6,8 @@ import { useShade } from '@tryghost/shade/app'; import { isSafeHref } from './is-safe-href'; import { parseMemberEvent } from './member-event'; import { useMemberActivityFeed } from '@tryghost/admin-x-framework/api/members'; +import { activityQueryOptions } from '@/members/activity/activity-filters'; +import { useActivitySettings } from '@/members/activity/use-activity-settings'; import type { MemberActivityEvent } from '@tryghost/admin-x-framework/api/members'; import type { ParsedMemberEvent } from './member-event'; @@ -60,6 +62,8 @@ export const EventIcon: React.FC<{ iconName: string }> = ({ iconName }) => { return ; case 'event-email-changed': return ; + case 'event-metafields-changed': + return ; case 'event-comment': return ; case 'event-click': @@ -203,10 +207,24 @@ const MemberActivityFeed: React.FC = ({ // behind the "View all" link. On the create screen there's no memberId // to query against, so disable the fetch entirely — an unsaved member has // no events by definition. - const { data, isLoading } = useMemberActivityFeed(memberId ?? '', { + // Leaves out the same events the member's full activity page does, so following + // "View all" never shows a different set of events. + const { settingsQuery, activitySettings } = useActivitySettings(); + const { excludedEvents } = activityQueryOptions({ + settings: activitySettings, + memberId, + excluded: null, + }); + // Waits for settings rather than asking twice. If they fail to load it still asks, with + // the defaults: the preview may then include newsletter or comment events a site has + // turned off, where the full page shows an error instead, but that beats a preview + // stuck showing no activity. + const { data, isLoading: feedLoading } = useMemberActivityFeed(memberId ?? '', { limit: '5', - enabled: !!memberId, + enabled: !!memberId && !settingsQuery.isLoading, + excludedEvents, }); + const isLoading = settingsQuery.isLoading || feedLoading; const rawEvents: MemberActivityEvent[] = data?.events ?? []; const events = rawEvents.map((rawEvent) => parseMemberEvent(rawEvent, { diff --git a/apps/admin/src/members/detail/member-detail.tsx b/apps/admin/src/members/detail/member-detail.tsx index 374f6801663..b5919572158 100644 --- a/apps/admin/src/members/detail/member-detail.tsx +++ b/apps/admin/src/members/detail/member-detail.tsx @@ -400,14 +400,16 @@ const MemberDetailPage: React.FC = ({ member={member} /> )} - + + + )} diff --git a/apps/admin/src/members/detail/member-event.test.ts b/apps/admin/src/members/detail/member-event.test.ts index 18981a062db..7ef5fd281a3 100644 --- a/apps/admin/src/members/detail/member-event.test.ts +++ b/apps/admin/src/members/detail/member-event.test.ts @@ -64,6 +64,7 @@ describe('parseMemberEvent — icon', () => { ['email_failed_event', {}, 'event-email-delivery-failed'], ['email_complaint_event', {}, 'event-email-delivery-spam'], ['email_change_event', {}, 'event-email-changed'], + ['metafield_change_event', { source: 'portal', metafields: [] }, 'event-metafields-changed'], ['comment_event', {}, 'event-comment'], ['click_event', {}, 'event-click'], ['aggregated_click_event', {}, 'event-click'], @@ -164,6 +165,77 @@ describe('parseMemberEvent — action text', () => { ); }); + describe('metafield_change_event', () => { + const fields = (...names: string[]) => + names.map((name, index) => ({ namespace: 'custom', key: `field_${index}`, name })); + + it('names the fields a member changed, and where', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('Home address', 'Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Home address and Job title in Portal'); + }); + + it('names a single field on its own', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Job title in Portal'); + }); + + it('counts the rest once a list is too long to read at a glance', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'portal', + metafields: fields('A', 'B', 'C', 'D', 'E'), + }), + defaultCtx, + ).action, + ).toBe('updated A, B, C and 2 more fields in Portal'); + }); + + it('reads the place a change was made after the fields', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { source: 'admin_api', metafields: fields('Job title') }), + defaultCtx, + ).action, + ).toBe('updated Job title through the Admin API'); + }); + + it('does not claim a place it cannot name', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { + source: 'somewhere_new', + metafields: fields('Job title'), + }), + defaultCtx, + ).action, + ).toBe('updated Job title'); + }); + + it('describes an entry that names no fields as a generic change', () => { + expect( + parseMemberEvent( + ev('metafield_change_event', { source: 'portal', metafields: [] }), + defaultCtx, + ).action, + ).toBe('updated custom fields in Portal'); + }); + }); + it('automated_email_sent_event with free/paid slug → welcome (Free/Paid)', () => { expect( parseMemberEvent( diff --git a/apps/admin/src/members/detail/member-event.ts b/apps/admin/src/members/detail/member-event.ts index 5a7597ef5af..2f3399305ad 100644 --- a/apps/admin/src/members/detail/member-event.ts +++ b/apps/admin/src/members/detail/member-event.ts @@ -1,3 +1,9 @@ +import { + isMetafieldChangeSource, + type MetafieldChangeEntry, + type MetafieldChangeSource, +} from '@tryghost/metafield-types'; + // Port of Ember `app/helpers/parse-member-event.js`. // The Ember helper is a Glimmer helper with injected services; we take the // tiny slice of settings-derived context it actually reads (`hasMultipleTiers`, @@ -180,9 +186,57 @@ function getIcon(event: RawMemberEvent): string { if (event.type === 'email_change_event') { icon = 'email-changed'; } + if (event.type === 'metafield_change_event') { + icon = 'metafields-changed'; + } return `event-${icon}`; } +/** Past this many, the rest of a list of field names is counted rather than named. */ +const NAMED_FIELDS_LIMIT = 3; + +/** + * A change to a member's custom fields, as the members events endpoint sends it. Typed + * rather than checked at runtime, like every other response from Ghost's own API: the + * server owns this shape and Admin trusts it. + */ +interface MetafieldChangeEvent extends RawMemberEvent { + type: 'metafield_change_event'; + data: RawMemberEvent['data'] & MetafieldChangeEntry; +} + +function isMetafieldChangeEvent(event: RawMemberEvent): event is MetafieldChangeEvent { + return event.type === 'metafield_change_event'; +} + +// Where a change was made, as it reads after the fields. Keyed by the shared vocabulary, +// so a place added on the server does not compile here until it has a label. A newer +// server than this Admin can still send one it does not know, which is left out. +const METAFIELD_CHANGE_PLACES: Record = { + admin: 'in Admin', + admin_api: 'through the Admin API', + import: 'from an import', + checkout: 'at checkout', + portal: 'in Portal', +}; + +function metafieldChangeAction(event: MetafieldChangeEvent): string { + const names = event.data.metafields.map(({ name }) => name); + + let changed = 'custom fields'; + if (names.length > NAMED_FIELDS_LIMIT) { + const rest = names.length - NAMED_FIELDS_LIMIT; + changed = `${names.slice(0, NAMED_FIELDS_LIMIT).join(', ')} and ${rest} more ${rest === 1 ? 'field' : 'fields'}`; + } else if (names.length > 0) { + changed = + names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`; + } + + const { source } = event.data; + const place = isMetafieldChangeSource(source) ? METAFIELD_CHANGE_PLACES[source] : undefined; + return place ? `updated ${changed} ${place}` : `updated ${changed}`; +} + function getAction(event: RawMemberEvent, hasMultipleNewsletters: boolean): string | undefined { if ( event.type === 'signup_event' || @@ -271,6 +325,9 @@ function getAction(event: RawMemberEvent, hasMultipleNewsletters: boolean): stri } return 'Email address changed'; } + if (isMetafieldChangeEvent(event)) { + return metafieldChangeAction(event); + } if (event.type === 'donation_event') { return 'Made a one-time payment'; } diff --git a/apps/admin/src/members/hooks/use-members-filter-state.ts b/apps/admin/src/members/hooks/use-members-filter-state.ts index 51c6193768e..579556b7f2d 100644 --- a/apps/admin/src/members/hooks/use-members-filter-state.ts +++ b/apps/admin/src/members/hooks/use-members-filter-state.ts @@ -116,6 +116,12 @@ export function useMembersFilterState( ); const [searchParams, setSearchParams] = useSearchParams(); const lastWrittenQueryRef = useRef(null); + // Set when the URL changed from outside (a link, a saved view, back) and the draft is being + // replaced from it. The write effect runs in the same commit with the previous draft still + // in its closure, and would write those stale filters over the new URL; a navigation that + // arrived meanwhile could then be lost. Skipping that one write lets the next render, with + // the new draft, normalise the URL instead. + const draftReplacedFromUrlRef = useRef(false); const filterParam = useMemo(() => searchParams.get('filter') ?? undefined, [searchParams]); const currentQuery = useMemo(() => searchParams.toString(), [searchParams]); @@ -134,12 +140,18 @@ export function useMembersFilterState( useEffect(() => { if (currentQuery !== lastWrittenQueryRef.current) { + draftReplacedFromUrlRef.current = lastWrittenQueryRef.current !== null; setDraftFilters(parsedFilters); lastWrittenQueryRef.current = currentQuery; } }, [currentQuery, parsedFilters]); useEffect(() => { + if (draftReplacedFromUrlRef.current) { + draftReplacedFromUrlRef.current = false; + return; + } + if (lastWrittenQueryRef.current !== null && currentQuery !== lastWrittenQueryRef.current) { return; } diff --git a/apps/admin/src/members/members-filtering.acceptance.test.tsx b/apps/admin/src/members/members-filtering.acceptance.test.tsx index 9e8ff2f6b76..a55d2b243a8 100644 --- a/apps/admin/src/members/members-filtering.acceptance.test.tsx +++ b/apps/admin/src/members/members-filtering.acceptance.test.tsx @@ -9,6 +9,7 @@ import { label, member, renderAdminApp, + settingsResponse, tier, } from '@test-utils/acceptance'; import { membersScreen } from './members.screen'; @@ -180,4 +181,74 @@ describe('Members list', () => { await expect(membersScreen.memberRows()).toHaveCount(1); await expect.element(membersScreen.link('Alice Alpha')).toBeVisible(); }); + + // A navigation that lands while the list is syncing its filters to the URL must not be + // overwritten: leaving a saved view and clicking straight back lands on the view. + it('reopens a saved view clicked straight after leaving it', async () => { + const vip = label({ name: 'VIP' }); + const viewFilter = 'label:[VIP]'; + fakeMembers(({ filter }) => + filter + ? [member({ name: 'Vip One', labels: [vip] })] + : [member({ name: 'Vip One', labels: [vip] }), member({ name: 'Plain' })], + ); + await renderAdminApp(`/members?filter=${encodeURIComponent(viewFilter)}`, { + boot: { + browseSettings: { + response: settingsResponse({ + settings: { + shared_views: JSON.stringify([ + { name: 'VIPs', route: 'members', filter: { filter: viewFilter } }, + ]), + }, + }), + }, + }, + }); + await expect(membersScreen.memberRows()).toHaveCount(1); + + const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + // The list's own "Members" link, not the heading: the sidebar has one by that href. + const membersLink = () => + document.querySelector('a[href="#/members"]') ?? undefined; + await expect.element(membersScreen.link('VIPs')).toBeVisible(); + + const onTheView = () => + currentRoute().includes('filter=') && + document.querySelectorAll('[data-slot="filter-item"]').length === 1 && + membersScreen.memberRows().elements().length === 1; + + // How quickly the second click follows the first is what exposed the race, so a few + // short gaps are tried, each starting from the view. Clicks go straight to the DOM so + // the gap between them is the one chosen. + for (const gap of [5, 10, 20, 30]) { + membersLink()!.click(); + await wait(gap); + const viewLink = membersScreen.link('VIPs').element(); + if (!(viewLink instanceof HTMLElement)) { + throw new Error('The saved view link is not a clickable element'); + } + viewLink.click(); + + // Straight after the clicks the page can still show the view it is leaving, so one look + // proves nothing. Wait until it rests on the view; a stale write leaves it for good. + let onViewSince: number | undefined; + await expect + .poll( + () => { + if (!onTheView()) { + onViewSince = undefined; + return false; + } + onViewSince ??= performance.now(); + return performance.now() - onViewSince >= 500; + }, + { message: `settles on the view after a ${gap}ms gap`, timeout: 10_000, interval: 20 }, + ) + .toBe(true); + } + }); }); diff --git a/apps/admin/src/members/members.tsx b/apps/admin/src/members/members.tsx index 9b11c110d2d..9fe2889de87 100644 --- a/apps/admin/src/members/members.tsx +++ b/apps/admin/src/members/members.tsx @@ -225,7 +225,6 @@ const MembersPage: React.FC = ({ - {isAdmin7 && headerFilters} {headerSearch} {!isAdmin7 && headerFilters} = ({ onImportComplete={() => { void refetch(); }} - /> + > + {isAdmin7 && headerFilters} + diff --git a/apps/admin/src/settings/advanced/history-modal.tsx b/apps/admin/src/settings/advanced/history-modal.tsx index 290a0f173a6..5e1a56965a2 100644 --- a/apps/admin/src/settings/advanced/history-modal.tsx +++ b/apps/admin/src/settings/advanced/history-modal.tsx @@ -32,7 +32,8 @@ import { useParams } from '@tryghost/admin-x-framework'; import { useSettingsNavigation } from '@/settings/hooks/use-settings-navigation'; import { SettingsModal } from '@tryghost/shade/patterns'; import { type User } from '@tryghost/admin-x-framework/api/users'; -import { formatNumber } from '@tryghost/shade/utils'; +import { cn, formatNumber } from '@tryghost/shade/utils'; +import { useShade } from '@tryghost/shade/app'; import { keepPreviousData } from '@tanstack/react-query'; import { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useFilterableApi } from '@tryghost/admin-x-framework/hooks'; @@ -96,6 +97,7 @@ const HistoryFilter: React.FC<{ toggleEventType: (event: string, included: boolean) => void; toggleResourceType: (resource: string, included: boolean) => void; }> = ({ userId, excludedEvents, excludedResources, toggleEventType, toggleResourceType }) => { + const { isAdmin7 } = useShade(); const { updateRoute } = useSettingsNavigation(); const usersApi = useFilterableApi({ path: '/users/', @@ -257,7 +259,11 @@ const HistoryFilter: React.FC<{
= ({ }; const buttons = custom ? ( - ) : disabled ? ( diff --git a/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx index 077c08e6b85..94b41472183 100644 --- a/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx +++ b/apps/admin/src/settings/advanced/integrations/webhooks-table.tsx @@ -137,10 +137,9 @@ const WebhooksTable: React.FC<{ integration: Integration }> = ({ integration }) + {isAdmin7 ? ( + + ) : ( + + )} {/* legacy SettingsModal overlay is z-[1000]; keep the portalled menu above it */} diff --git a/apps/admin/src/settings/general/users.tsx b/apps/admin/src/settings/general/users.tsx index f36d6a3f77a..67ed8165203 100644 --- a/apps/admin/src/settings/general/users.tsx +++ b/apps/admin/src/settings/general/users.tsx @@ -244,11 +244,10 @@ const UserInviteActions: React.FC<{ invite: UserInvite }> = ({ invite }) => { return (
); diff --git a/apps/admin/test-utils/fixtures/dunning.ts b/apps/admin/test-utils/fixtures/dunning.ts new file mode 100644 index 00000000000..d9b21cf1fee --- /dev/null +++ b/apps/admin/test-utils/fixtures/dunning.ts @@ -0,0 +1,37 @@ +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * A host-config dunning block `elapsedDays` into a `windowDays`-day + * paymentFailedAt -> suspendsAt window. Anchored on `now` (defaults to the + * current clock, so tests under fake timers get their frozen time). + */ +export function dunningWindow( + elapsedDays: number, + { windowDays = 28, now = Date.now() }: { windowDays?: number; now?: number } = {}, +) { + return { + active: true, + paymentFailedAt: new Date(now - elapsedDays * DAY_MS).toISOString(), + suspendsAt: new Date(now + (windowDays - elapsedDays) * DAY_MS).toISOString(), + }; +} + +/** + * A mocked `useBrowseConfig` return for a host carrying the given dunning + * block, with the dunningWarnings flag on unless overridden via `labs`. + */ +export function browseConfigWithDunning( + dunning?: unknown, + labs: Record = { dunningWarnings: true }, +) { + return { + data: { + config: { + labs, + hostSettings: { + billing: { enabled: true, url: 'https://billing.example.com', dunning }, + }, + }, + }, + }; +} diff --git a/apps/ember-admin/app/components/gh-billing-iframe.js b/apps/ember-admin/app/components/gh-billing-iframe.js index ad6d0743300..52dad609414 100644 --- a/apps/ember-admin/app/components/gh-billing-iframe.js +++ b/apps/ember-admin/app/components/gh-billing-iframe.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import {action} from '@ember/object'; import {htmlSafe} from '@ember/template'; import {inject} from 'ghost-admin/decorators/inject'; +import {parseDunningConfig} from '@tryghost/admin-x-framework/api/dunning'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; @@ -169,8 +170,17 @@ export default class GhBillingIframe extends Component { this.config.hostSettings.forceUpgrade = false; } - // Detect if the current subscription is in a grace state and render a notification - if (data.subscription.status === 'past_due' || data.subscription.status === 'unpaid') { + // Detect if the current subscription is in a grace state and render a notification. + // The dunningWarnings flag replaces this alert with the React admin's own + // payment-failure warning states, so it stands down while the flag is on — + // but only when React can use the host's dunning block. Missing or + // malformed config must leave the existing overdue alert available. + const dunningWarningsActive = this.feature.dunningWarnings + && parseDunningConfig(this.config.hostSettings?.billing?.dunning) !== null; + if ( + (data.subscription.status === 'past_due' || data.subscription.status === 'unpaid') + && !dunningWarningsActive + ) { // This notification needs to be shown to every user regardless their permissions to see billing this.notifications.showAlert(htmlSafe(`Your billing details need updating. The site owner must update payment information to avoid suspension.`), {type: 'error', key: 'billing.overdue'}); } else { diff --git a/apps/ember-admin/app/controllers/members-activity.js b/apps/ember-admin/app/controllers/members-activity.js index 36989fc776e..11ca289bd6a 100644 --- a/apps/ember-admin/app/controllers/members-activity.js +++ b/apps/ember-admin/app/controllers/members-activity.js @@ -2,6 +2,7 @@ import Controller from '@ember/controller'; import MemberFetcher from 'ghost-admin/helpers/member-fetcher'; import {EMAIL_EVENTS, NEWSLETTER_EVENTS} from 'ghost-admin/helpers/members-event-filter'; import {action} from '@ember/object'; +import {inject} from 'ghost-admin/decorators/inject'; import {inject as service} from '@ember/service'; import {tracked} from '@glimmer/tracking'; import {use} from 'ember-could-get-used-to-this'; @@ -10,6 +11,7 @@ export default class MembersActivityController extends Controller { @service router; @service settings; @service store; + @inject config; @service feature; queryParams = ['excludedEvents', 'member']; @@ -22,6 +24,7 @@ export default class MembersActivityController extends Controller { // we don't want to show or allow filtering of certain events in some situations // - no member selected = don't show email events, they flood the list and the API can't paginate correctly // - newsletter is disabled = don't show email or newletter events + // - custom fields are unavailable = don't show custom field changes get hiddenEvents() { const hiddenEvents = []; @@ -34,6 +37,12 @@ export default class MembersActivityController extends Controller { hiddenEvents.push(...EMAIL_EVENTS, ...NEWSLETTER_EVENTS); } + // Same availability rule as React Admin: the labs flag, and the host limit. + const customFieldsLimited = this.config.hostSettings?.limits?.limitCustomFields?.disabled === true; + if (!this.feature.membersCustomFields || customFieldsLimited) { + hiddenEvents.push('metafield_change_event'); + } + return hiddenEvents; } diff --git a/apps/ember-admin/app/helpers/parse-member-event.js b/apps/ember-admin/app/helpers/parse-member-event.js index 13030377d36..ef128e54ecc 100644 --- a/apps/ember-admin/app/helpers/parse-member-event.js +++ b/apps/ember-admin/app/helpers/parse-member-event.js @@ -158,9 +158,39 @@ export default class ParseMemberEventHelper extends Helper { icon = 'email-changed'; } + if (event.type === 'metafield_change_event') { + icon = 'metafields-changed'; + } + return 'event-' + icon; } + // Mirrors the React Admin activity feed, which describes the same event. + getMetafieldChangeAction(event) { + const namedFieldsLimit = 3; + const places = { + admin: 'in Admin', + admin_api: 'through the Admin API', + import: 'from an import', + checkout: 'at checkout', + portal: 'in Portal' + }; + const names = (event.data.metafields || []).map(field => field.name); + + let changed = 'custom fields'; + if (names.length > namedFieldsLimit) { + const rest = names.length - namedFieldsLimit; + changed = `${names.slice(0, namedFieldsLimit).join(', ')} and ${rest} more ${rest === 1 ? 'field' : 'fields'}`; + } else if (names.length === 1) { + changed = names[0]; + } else if (names.length > 1) { + changed = `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; + } + + const place = Object.hasOwn(places, event.data.source) ? places[event.data.source] : null; + return place ? `updated ${changed} ${place}` : `updated ${changed}`; + } + getAction(event, hasMultipleNewsletters) { if (event.type === 'signup_event' || (event.type === 'subscription_event' && event.data.type === 'created' && event.data.signup)) { return 'signed up'; @@ -270,6 +300,10 @@ export default class ParseMemberEventHelper extends Helper { return 'Email address changed'; } + if (event.type === 'metafield_change_event') { + return this.getMetafieldChangeAction(event); + } + if (event.type === 'donation_event') { return 'Made a one-time payment'; } diff --git a/apps/ember-admin/app/services/billing.js b/apps/ember-admin/app/services/billing.js index 56446d55279..7eeebbed6fa 100644 --- a/apps/ember-admin/app/services/billing.js +++ b/apps/ember-admin/app/services/billing.js @@ -1,5 +1,10 @@ import * as Sentry from '@sentry/ember'; import Service, {inject as service} from '@ember/service'; +import { + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY, + parseDunningConfig +} from '@tryghost/admin-x-framework/api/dunning'; import {inject} from 'ghost-admin/decorators/inject'; import {tracked} from '@glimmer/tracking'; @@ -14,6 +19,11 @@ const NEWSLETTERS_DESTINATION = 'newsletters'; const NEWSLETTERS_ROUTE_WITH_AUTOMATIONS = '/settings/emails'; const NEWSLETTERS_ROUTE = '/settings/newsletters'; +// Not a route: asks Admin to go back to the page the user was on before the +// billing screen. Sent by the payment page's return flow after a successful +// payment, so a "Pay now" click from e.g. the editor lands back in the editor. +const PREVIOUS_PAGE_DESTINATION = 'previousPage'; + // Approved destinations the Billing app may request Ghost Admin to navigate to, // mapped to the Admin route that owns them. Ghost Admin owns this mapping — the // Billing app never sends raw URLs or routes. A null-prototype, frozen object is @@ -132,6 +142,33 @@ export default class BillingService extends Service { return; } + if (destination === PREVIOUS_PAGE_DESTINATION) { + this._markDunningPaymentSettled(); + + // Still only semantic navigation: no route or URL crosses the + // iframe boundary — the Admin side records where "Pay now" was + // clicked. Without a recorded route (a direct deep link to the + // payment page) the billing overview is the fallback; never + // history.back(), whose previous entry can lie outside Admin. + const returnRoute = this._takePayNowReturnRoute(); + + if (returnRoute) { + // The recorded route is same-origin Admin data, but it may no + // longer resolve (e.g. a page behind a since-disabled flag) — + // transitionTo throws on an unrecognized URL rather than + // navigating, so fall back to the overview instead of dying + try { + this.router.transitionTo(returnRoute); + return; + } catch (e) { + // fall through to the billing overview + } + } + + this.router.transitionTo('pro'); + return; + } + const route = this._resolveAdminDestinationRoute(destination); if (!route) { @@ -141,6 +178,44 @@ export default class BillingService extends Service { this.router.transitionTo(route); } + _markDunningPaymentSettled() { + if (!this.feature.dunningWarnings) { + return; + } + + const dunning = parseDunningConfig(this.config.hostSettings?.billing?.dunning); + if (!dunning) { + return; + } + + // Identify the failure from the boot config, not the browser's clock. + // A later failure must not inherit this payment's suppression. + try { + window.sessionStorage.setItem( + DUNNING_PAYMENT_SETTLED_STORAGE_KEY, + dunning.paymentFailedAt.toISOString() + ); + } catch (e) { + // Without storage the warnings stand down when the refreshed + // subscription state arrives instead + } + } + + // Consumes the route recorded by a dunning "Pay now" CTA (written by + // apps/admin/src/dunning) — once per payment return. + _takePayNowReturnRoute() { + try { + const route = window.sessionStorage.getItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY); + window.sessionStorage.removeItem(DUNNING_PAY_RETURN_ROUTE_STORAGE_KEY); + // Absolute Admin paths only: exactly one leading slash — '//host' + // is a protocol-relative URL, not a route + return route && route.startsWith('/') && !route.startsWith('//') ? route : null; + } catch (e) { + // Storage can be unavailable; fall back to the billing overview + return null; + } + } + _resolveAdminDestinationRoute(destination) { if (destination === NEWSLETTERS_DESTINATION) { return this.feature.automations ? NEWSLETTERS_ROUTE_WITH_AUTOMATIONS : NEWSLETTERS_ROUTE; diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index 495cbdc1d02..ccfa42b589e 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -102,8 +102,10 @@ export default class FeatureService extends Service { @feature('csvContentImporter') csvContentImporter; @feature('postsListReact') postsListReact; @feature('membersActivityReact') membersActivityReact; + @feature('membersCustomFields') membersCustomFields; @feature('editorReact') editorReact; @feature('improveSendingUI') improveSendingUI; + @feature('dunningWarnings') dunningWarnings; _user = null; _featureFlagOverridesRevision = 0; diff --git a/apps/ember-admin/app/utils/member-event-types.js b/apps/ember-admin/app/utils/member-event-types.js index 94db184f3f6..4897039af22 100644 --- a/apps/ember-admin/app/utils/member-event-types.js +++ b/apps/ember-admin/app/utils/member-event-types.js @@ -10,6 +10,7 @@ export const ALL_EVENT_TYPES = [ {event: 'email_failed_event', icon: 'filter-dropdown-email-bounced', name: 'Email bounced', group: 'emails'}, {event: 'email_change_event', icon: 'filter-dropdown-email-address-changed', name: 'Email address changed', group: 'emails'}, {event: 'automated_email_sent_event', icon: 'filter-dropdown-email-received', name: 'Welcome email received', group: 'emails'}, + {event: 'metafield_change_event', icon: 'filter-dropdown-custom-fields-updated', name: 'Custom fields updated', group: 'others'}, {event: 'feedback_event', icon: 'filter-dropdown-feedback', name: 'Feedback', group: 'others'} ]; diff --git a/apps/ember-admin/public/assets/icons/event-metafields-changed.svg b/apps/ember-admin/public/assets/icons/event-metafields-changed.svg new file mode 100644 index 00000000000..96678d20b15 --- /dev/null +++ b/apps/ember-admin/public/assets/icons/event-metafields-changed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg b/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg new file mode 100644 index 00000000000..3fa915ed137 --- /dev/null +++ b/apps/ember-admin/public/assets/icons/filter-dropdown-custom-fields-updated.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/ember-admin/tests/acceptance/members-activity-test.js b/apps/ember-admin/tests/acceptance/members-activity-test.js index a97cd2027ff..6dbb16976bb 100644 --- a/apps/ember-admin/tests/acceptance/members-activity-test.js +++ b/apps/ember-admin/tests/acceptance/members-activity-test.js @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {click, currentURL, find, findAll} from '@ember/test-helpers'; import {describe, it} from 'mocha'; +import {enableLabsFlag} from '../helpers/labs-flag'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -99,6 +100,45 @@ describe('Acceptance: Members activity', function () { expect(findAll('.gh-members-activity-event').length).to.equal(1); }); + describe('custom field changes', function () { + beforeEach(function () { + this.server.create('member-activity-event', { + memberId: 1, + createdAt: moment('2024-08-19 08:18:08').format('YYYY-MM-DD HH:mm:ss'), + type: 'metafield_change_event', + data: { + source: 'portal', + metafields: [{namespace: 'custom', key: 'job_title', name: 'Job title'}] + } + }); + }); + + it('names the fields and where they were changed, and can be filtered', async function () { + enableLabsFlag(this.server, 'membersCustomFields'); + + await visit('/members-activity'); + expect(find('.gh-members-activity-event-text').textContent.trim()).to.equal('Updated Job title in Portal'); + + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.exist; + }); + + it('is not offered where custom fields are not available', async function () { + await visit('/members-activity'); + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.not.exist; + }); + + it('is not offered when the plan does not include custom fields', async function () { + enableLabsFlag(this.server, 'membersCustomFields'); + this.server.db.configs.update(1, {hostSettings: {limits: {limitCustomFields: {disabled: true}}}}); + + await visit('/members-activity'); + await click('[data-test-id="filter-events-button"]'); + expect(find('[data-test-id="event-type-filter-checkbox-metafield_change_event"]')).to.not.exist; + }); + }); + it('includes one time (donation) payments under payments filtering', async function () { await visit('/members-activity'); await click('[data-test-id="filter-events-button"]'); diff --git a/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js b/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js index 64110b5f1cb..34f2ee1b1cd 100644 --- a/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js +++ b/apps/ember-admin/tests/integration/components/gh-billing-iframe-test.js @@ -195,6 +195,77 @@ describe('Integration: Component: gh-billing-iframe', function () { expect(transitionTo.calledOnceWithExactly('/settings/newsletters')).to.be.true; }); + it('shows the overdue billing alert for a delinquent subscription', async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + + await render(hbs``); + + await postBillingMessage({subscription: {status: 'past_due'}}); + + expect(showAlert.calledOnce).to.be.true; + expect(showAlert.firstCall.args[1]).to.include({type: 'error', key: 'billing.overdue'}); + }); + + it('stands the overdue alert down when the dunningWarnings flag is enabled', async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + const closeAlerts = sinon.stub(notifications, 'closeAlerts'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + const feature = this.owner.lookup('service:feature'); + sinon.stub(feature, 'dunningWarnings').get(() => true); + const config = this.owner.lookup('config:main'); + config.hostSettings = { + ...config.hostSettings, + billing: { + ...config.hostSettings?.billing, + dunning: {active: true, paymentFailedAt: '2026-08-26', suspendsAt: '2026-09-23'} + } + }; + + await render(hbs``); + + await postBillingMessage({subscription: {status: 'past_due'}}); + + expect(showAlert.called).to.be.false; + expect(closeAlerts.calledWith('billing.overdue')).to.be.true; + }); + + for (const [label, dunning] of [ + ['missing config', undefined], + ['missing dates', {active: true}], + ['invalid dates', {active: true, paymentFailedAt: 'invalid', suspendsAt: '2026-09-29'}], + ['non-string dates', {active: true, paymentFailedAt: 1, suspendsAt: '2026-09-29'}], + ['an inverted window', {active: true, paymentFailedAt: '2026-09-29', suspendsAt: '2026-09-01'}], + ['an empty window', {active: true, paymentFailedAt: '2026-09-01', suspendsAt: '2026-09-01'}] + ]) { + for (const status of ['past_due', 'unpaid']) { + it(`keeps the overdue alert for ${status} with ${label}`, async function () { + const notifications = this.owner.lookup('service:notifications'); + const showAlert = sinon.stub(notifications, 'showAlert'); + const closeAlerts = sinon.stub(notifications, 'closeAlerts'); + sinon.stub(this.owner.lookup('service:config-manager'), 'fetch').resolves(); + sinon.stub(this.owner.lookup('service:limit'), 'reload'); + sinon.stub(this.owner.lookup('service:feature'), 'dunningWarnings').get(() => true); + const config = this.owner.lookup('config:main'); + config.hostSettings = { + ...config.hostSettings, + billing: {...config.hostSettings?.billing, dunning} + }; + + await render(hbs``); + await postBillingMessage({subscription: {status}}); + + expect(showAlert.calledOnce).to.be.true; + expect(showAlert.firstCall.args[1]).to.include({type: 'error', key: 'billing.overdue'}); + expect(closeAlerts.calledWith('billing.overdue')).to.be.false; + }); + } + } + it('ignores a navigateToAdmin message with an unknown destination', async function () { const router = this.owner.lookup('service:router'); const transitionTo = sinon.stub(router, 'transitionTo'); diff --git a/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js b/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js index b17c773bf5e..5345912171f 100644 --- a/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js +++ b/apps/ember-admin/tests/unit/helpers/parse-member-event-test.js @@ -154,4 +154,33 @@ describe('Unit: Helper: parse-member-event', function () { expect(result.icon).to.equal('event-gift'); }); }); + + describe('metafield_change_event', function () { + function fields(...names) { + return names.map((name, index) => ({namespace: 'custom', key: `field_${index}`, name})); + } + + it('names the fields that changed and where', function () { + const event = buildEvent({ + type: 'metafield_change_event', + data: {source: 'portal', metafields: fields('Home address', 'Job title')} + }); + const result = helper.compute([event]); + expect(result.action).to.equal('updated Home address and Job title in Portal'); + expect(result.icon).to.equal('event-metafields-changed'); + }); + + it('counts the rest once a list is too long to read at a glance', function () { + const event = buildEvent({ + type: 'metafield_change_event', + data: {source: 'import', metafields: fields('A', 'B', 'C', 'D', 'E')} + }); + expect(helper.compute([event]).action).to.equal('updated A, B, C and 2 more fields from an import'); + }); + + it('leaves out a place it does not know', function () { + const event = buildEvent({type: 'metafield_change_event', data: {source: 'somewhere_new', metafields: fields('Job title')}}); + expect(helper.compute([event]).action).to.equal('updated Job title'); + }); + }); }); diff --git a/apps/ember-admin/tests/unit/services/billing-test.js b/apps/ember-admin/tests/unit/services/billing-test.js index 95c35a06fd7..e6a4bf9a3fd 100644 --- a/apps/ember-admin/tests/unit/services/billing-test.js +++ b/apps/ember-admin/tests/unit/services/billing-test.js @@ -34,6 +34,8 @@ describe('Unit: Service: billing', function () { billingService?.clearBillingAppLoadMonitor(); billingService = null; sinon.restore(); + window.sessionStorage.removeItem('ghost-dunning-pay-return-route'); + window.sessionStorage.removeItem('ghost-dunning-payment-settled-for'); }); it('retries loading the billing app before reporting', async function () { @@ -434,6 +436,108 @@ describe('Unit: Service: billing', function () { expect(transitionTo.calledOnceWithExactly('/settings/newsletters')).to.be.true; }); + it('returns to the recorded route for the previousPage destination', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '/editor/post/abc123'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('/editor/post/abc123')).to.be.true; + // consumed: a later return without a fresh "Pay now" click must not reuse it + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + for (const now of ['2026-07-01T00:00:00Z', '2026-11-01T00:00:00Z']) { + it(`records the settled failure with the client clock at ${now}`, function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + sinon.stub(service.router, 'transitionTo'); + sinon.stub(service.feature, 'dunningWarnings').get(() => true); + sinon.useFakeTimers({now: new Date(now), toFake: ['Date']}); + const config = this.owner.lookup('config:main'); + config.hostSettings.billing.dunning = { + active: true, + paymentFailedAt: '2026-09-01T04:00:00+04:00', + suspendsAt: '2026-09-29T00:00:00Z' + }; + + service.navigateToAdminDestination('previousPage'); + + expect(window.sessionStorage.getItem('ghost-dunning-payment-settled-for')) + .to.equal('2026-09-01T00:00:00.000Z'); + }); + } + + for (const [label, enabled, dunning] of [ + ['flag disabled', false, {active: true, paymentFailedAt: '2026-09-01', suspendsAt: '2026-09-29'}], + ['missing config', true, undefined], + ['malformed config', true, {active: true, paymentFailedAt: 'invalid', suspendsAt: '2026-09-29'}] + ]) { + it(`does not record a settled failure with ${label}`, function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + sinon.stub(service.feature, 'dunningWarnings').get(() => enabled); + this.owner.lookup('config:main').hostSettings.billing.dunning = dunning; + + service.navigateToAdminDestination('previousPage'); + + expect(window.sessionStorage.getItem('ghost-dunning-payment-settled-for')).to.be.null; + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + }); + } + + it('falls back to the billing overview without a recorded return route', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.removeItem('ghost-dunning-pay-return-route'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + }); + + it('ignores a recorded return route that is not an absolute path', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', 'https://evil.example'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + it('ignores a protocol-relative recorded return route', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + // '//host' passes a bare startsWith('/') check but is a URL, not a route + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '//evil.example'); + + service.navigateToAdminDestination('previousPage'); + + expect(transitionTo.calledOnceWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + + it('falls back to the billing overview when the recorded route does not resolve', function () { + const service = this.owner.lookup('service:billing'); + billingService = service; + const transitionTo = sinon.stub(service.router, 'transitionTo'); + transitionTo.withArgs('/behind-a-flag').throws(new Error('UnrecognizedURLError: /behind-a-flag')); + window.sessionStorage.setItem('ghost-dunning-pay-return-route', '/behind-a-flag'); + + expect(() => service.navigateToAdminDestination('previousPage')).to.not.throw(); + + expect(transitionTo.calledWithExactly('pro')).to.be.true; + expect(window.sessionStorage.getItem('ghost-dunning-pay-return-route')).to.be.null; + }); + it('ignores destinations that are not approved keys', function () { const service = this.owner.lookup('service:billing'); billingService = service; diff --git a/apps/shade/src/components/patterns/page-header.mdx b/apps/shade/src/components/patterns/page-header.mdx index 8e956ea1de9..f866805c982 100644 --- a/apps/shade/src/components/patterns/page-header.mdx +++ b/apps/shade/src/components/patterns/page-header.mdx @@ -14,17 +14,18 @@ require a different overall layout. Place actions in reading and keyboard order: -1. **Labelled secondary actions:** icon + label, ordered by importance or frequency. -2. **Icon-only utilities:** ordered by importance or frequency, with **More actions - (•••) last** when present. -3. **An optional single primary action:** icon + label, at the right edge. +1. **Icon-only secondary actions:** ordered by importance or frequency, with the + overflow menu (•••) last when present. +2. **Labelled secondary actions:** icon + label, ordered by importance or frequency. +3. **A gap** separating secondary actions from the primary. +4. **An optional single primary action:** icon + label, at the right edge. -For example: Filter → Date range → Search → More actions → New member. Importance +For example: Search → More actions → Filter → Date range → gap → New member. Importance is a product decision within each group. The component preserves the supplied DOM order; it does not infer importance or sort children. A page without a meaningful primary action should omit it. -`PageHeader.ActionGroup` provides **4px** between secondary controls. +`PageHeader.ActionGroup` provides **8px** between secondary controls. `PageHeader.ActionGroup.Primary` provides **20px** separation from the preceding secondary control. Put at most one primary action in that slot. A primary alone has no leading separation. Avoid additional margins or gaps on individual actions. @@ -42,8 +43,8 @@ trigger must be constructed outside that slot, such as a modal trigger. ```tsx - Filter + Filter New member @@ -56,7 +57,9 @@ stroke width, ghost variant, tooltip styling, or page-specific CSS. Secondary controls use the ghost treatment and **2px icon strokes**. Dropdown text has the same medium weight as Filter; header dropdown triggers hide their down chevrons. -Use `PageHeader.SelectTrigger` inside Select for labelled dropdowns. Compose +Use `PageHeader.SelectTrigger` inside Select for labelled header dropdowns; it +provides the pill shape. Ordinary `SelectTrigger` form fields keep standard rounded +corners. Compose `PageHeader.Action` with `DropdownMenuTrigger asChild` for action menus. Refs, accessible state and event handlers reach the underlying control. Dropdown and popover triggers retain the inset pressed treatment while expanded. diff --git a/apps/shade/src/components/patterns/page-header.stories.tsx b/apps/shade/src/components/patterns/page-header.stories.tsx index df0ff53653c..567affe21b5 100644 --- a/apps/shade/src/components/patterns/page-header.stories.tsx +++ b/apps/shade/src/components/patterns/page-header.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import React from 'react'; -import { ArrowUpDown, Calendar, Ellipsis, Plus, Save, Search } from 'lucide-react'; +import { ArrowUpDown, Ellipsis, Plus, Save, Search } from 'lucide-react'; import { PageHeader } from '@/components/patterns/page-header'; import { Filters, type Filter } from '@/components/patterns/filters'; import { @@ -13,6 +13,7 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/in import { Select, SelectContent, SelectItem, SelectValue } from '@/components/ui/select'; import { Stack } from '@/components/primitives'; import ShadeApp from '@/shade-app'; +import { useShade } from '@/providers/shade-provider'; import { formatNumber } from '@/utils'; import { FilterBar } from '@/components/patterns/filter-bar'; @@ -25,7 +26,7 @@ const meta = { docs: { description: { component: - 'Page titles and action groups with shared ordering, spacing, control and tooltip conventions. See the attached Design contract for construction rules.', + 'Page titles and action groups with shared ordering, spacing, control and tooltip conventions. See the [Page header design contract](?path=/docs/patterns-page-header--design-contract) for construction rules and the Structure story below for a live example.', }, }, }, @@ -51,6 +52,7 @@ function MoreActions() { } function SearchAction({ initialQuery = '' }: { initialQuery?: string }) { + const { controlShape } = useShade(); const [query, setQuery] = React.useState(initialQuery); const [expanded, setExpanded] = React.useState(!!initialQuery); const restoreTriggerFocus = React.useRef(false); @@ -72,7 +74,7 @@ function SearchAction({ initialQuery = '' }: { initialQuery?: string }) { ); } return ( - + @@ -142,9 +144,9 @@ function MembersHeader({ - {filters.length === 0 && filterControls} + {filters.length === 0 && filterControls} {mobile && ( @@ -180,7 +182,7 @@ export const Structure: Story = { docs: { description: { story: - 'Canonical list header: labelled filters, search, more actions, then the separated primary. Open menus and hover or Tab to inspect their states.', + 'Canonical action order: icon-only secondary actions, icon + label secondary actions, a gap, then a single primary action. Open menus and hover or Tab to inspect their states.', }, }, }, @@ -222,25 +224,25 @@ export const Subview: Story = { docs: { description: { story: - 'Analytics needs no invented primary action. Labelled controls retain their usage order.', + 'A header can contain only secondary actions. Keep their usual order and omit the primary-action gap.', }, }, }, render: () => ( - Analytics + Page title - + + - Last 7 days - Last 30 days + Newest first + Oldest first @@ -321,13 +323,13 @@ export const DisabledActions: Story = { }, render: () => ( + + + Filter - - - diff --git a/apps/shade/src/components/patterns/page-header.tsx b/apps/shade/src/components/patterns/page-header.tsx index dc6b274d8fb..9de2e58f33c 100644 --- a/apps/shade/src/components/patterns/page-header.tsx +++ b/apps/shade/src/components/patterns/page-header.tsx @@ -139,14 +139,19 @@ const PageHeaderSelectTrigger = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { label: string } >(({ label, className, ...props }, ref) => { - const { isAdmin7 } = useShade(); + const { controlShape, isAdmin7 } = useShade(); return ( {isAdmin7 ? ( @@ -388,7 +393,7 @@ const PageHeaderActionGroup: PageHeaderActionGroupComponent = Object.assign( mobileMenuBreakpoint = DEFAULT_MOBILE_MENU_BREAKPOINT, }: PageHeaderActionGroupProps) { const { isAdmin7 } = useShade(); - const gap = isAdmin7 ? 'xs' : 'sm'; + const gap = 'sm'; const childNodes = React.Children.toArray(children); const desktopChildren: React.ReactNode[] = []; let mobileMenu: React.ReactElement | null = null; diff --git a/apps/shade/src/components/ui/button.stories.tsx b/apps/shade/src/components/ui/button.stories.tsx index a27db036773..a5dde946a1a 100644 --- a/apps/shade/src/components/ui/button.stories.tsx +++ b/apps/shade/src/components/ui/button.stories.tsx @@ -118,6 +118,27 @@ export const Destructive: Story = { }, }; +export const DestructiveGhost: Story = { + args: { + variant: 'destructive-ghost', + children: 'Delete item', + }, + render: (args) => ( + + + diff --git a/apps/shade/src/components/ui/button.tsx b/apps/shade/src/components/ui/button.tsx index 35130d09b94..6c4074d8255 100644 --- a/apps/shade/src/components/ui/button.tsx +++ b/apps/shade/src/components/ui/button.tsx @@ -19,6 +19,7 @@ const buttonVariants = cva( secondary: 'font-medium text-secondary-foreground', subtle: 'font-medium', ghost: 'font-medium hover:bg-accent hover:text-accent-foreground', + 'destructive-ghost': 'font-medium text-destructive hover:text-destructive', link: 'font-medium text-primary underline-offset-4 hover:underline', dropdown: 'border border-control-border bg-transparent hover:bg-button-hover hover:text-accent-foreground', @@ -43,6 +44,13 @@ const buttonVariants = cva( className: 'bg-secondary hover:bg-secondary/80', }, { isAdmin7: true, variant: 'secondary', className: 'bg-tab-active hover:bg-secondary' }, + { isAdmin7: false, variant: 'destructive-ghost', className: 'hover:bg-accent' }, + { + isAdmin7: true, + variant: 'destructive-ghost', + className: + 'hover:bg-destructive/10 enabled:active:bg-destructive/10 enabled:aria-expanded:bg-destructive/10', + }, { isAdmin7: false, variant: 'subtle', @@ -56,7 +64,7 @@ const buttonVariants = cva( }, { isAdmin7: true, - variant: ['secondary', 'ghost', 'subtle'], + variant: ['secondary', 'ghost', 'destructive-ghost', 'subtle'], className: 'enabled:active:shadow-control-pressed enabled:aria-expanded:shadow-control-pressed', }, @@ -79,7 +87,15 @@ const buttonVariants = cva( }, { isAdmin7: true, - variant: ['destructive', 'outline', 'secondary', 'ghost', 'dropdown', 'subtle'], + variant: [ + 'destructive', + 'outline', + 'secondary', + 'ghost', + 'destructive-ghost', + 'dropdown', + 'subtle', + ], size: ['default', 'sm', 'lg'], className: 'px-3', }, diff --git a/apps/shade/src/components/ui/copy-field.stories.tsx b/apps/shade/src/components/ui/copy-field.stories.tsx index b784a760fc4..5d52ba131bf 100644 --- a/apps/shade/src/components/ui/copy-field.stories.tsx +++ b/apps/shade/src/components/ui/copy-field.stories.tsx @@ -17,7 +17,7 @@ const meta = { docs: { description: { component: - 'A read-only value row with hover-revealed actions and intrinsic clipboard feedback.', + 'A read-only value row with hover-revealed actions and intrinsic clipboard feedback. Admin 7 uses 24px-tall pill buttons inside the field.', }, }, }, diff --git a/apps/shade/src/components/ui/copy-field.tsx b/apps/shade/src/components/ui/copy-field.tsx index 06d8f8ee0c0..a87a4af788b 100644 --- a/apps/shade/src/components/ui/copy-field.tsx +++ b/apps/shade/src/components/ui/copy-field.tsx @@ -4,6 +4,7 @@ import { Inline, Stack, Text } from '@/components/primitives'; import { Button, type ButtonProps } from '@/components/ui/button'; import { inputSurface } from '@/components/ui/input-surface'; import { cn } from '@/lib/utils'; +import { useShade } from '@/providers/shade-provider'; type CopyFieldContextValue = { copied: boolean; @@ -161,18 +162,23 @@ const CopyFieldValue = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ), + ({ className, ...props }, ref) => { + const { isAdmin7 } = useShade(); + + return ( + + ); + }, ); CopyFieldActions.displayName = 'CopyFieldActions'; diff --git a/apps/shade/src/components/ui/input-group.stories.tsx b/apps/shade/src/components/ui/input-group.stories.tsx index 5ba38bfcaa9..71e39c69c77 100644 --- a/apps/shade/src/components/ui/input-group.stories.tsx +++ b/apps/shade/src/components/ui/input-group.stories.tsx @@ -26,7 +26,7 @@ const meta = { docs: { description: { component: - 'Display additional information or actions alongside an input or textarea. Use addons to provide context, actions, or keyboard shortcuts that enhance the input experience.', + 'Display additional information or actions alongside an input or textarea. Input groups keep standard rounded corners; buttons inside inherit the action-button shape. Use addons to provide context, actions, or keyboard shortcuts that enhance the input experience.', }, }, }, @@ -81,7 +81,7 @@ export const Icon: Story = { export const GhostPill: Story = { render: () => (
- + @@ -101,7 +101,7 @@ export const GhostPill: Story = { export const SecondaryPill: Story = { render: () => (
- + @@ -244,7 +244,7 @@ export const Textarea: Story = { docs: { description: { story: - 'Textarea and block-aligned addons keep rounded corners in both designs; pill shapes apply to single-line groups.', + 'Input groups use standard rounded corners. Textareas and block-aligned addons retain those corners even when a pill shape is requested.', }, }, }, diff --git a/apps/shade/src/components/ui/input-group.tsx b/apps/shade/src/components/ui/input-group.tsx index 06051deb9d5..5dd577efe8e 100644 --- a/apps/shade/src/components/ui/input-group.tsx +++ b/apps/shade/src/components/ui/input-group.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; -import { useShade } from '@/providers/shade-provider'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { inputSurfaceClasses } from '@/components/ui/input-surface'; @@ -42,7 +41,7 @@ const inputGroupVariants = cva( }, defaultVariants: { variant: 'default', - shape: 'pill', + shape: 'rounded', }, }, ); @@ -51,8 +50,7 @@ export interface InputGroupProps extends React.ComponentProps<'div'>, VariantProps {} function InputGroup({ className, variant, shape, ...props }: InputGroupProps) { - const { controlShape } = useShade(); - const resolvedShape = shape ?? controlShape; + const resolvedShape = shape ?? 'rounded'; return (
svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", - sm: 'h-8 gap-1.5 rounded-control px-2.5 has-[>svg]:px-2.5', - 'icon-xs': 'size-6 rounded-[calc(var(--input-group-radius)-5px)] p-0 has-[>svg]:p-0', + xs: "h-6 gap-1 px-2 has-[>svg]:px-2 data-[control-shape=rounded]:rounded-[calc(var(--input-group-radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5", + sm: 'h-8 gap-1.5 px-2.5 has-[>svg]:px-2.5 data-[control-shape=rounded]:rounded-control', + 'icon-xs': + 'size-6 p-0 has-[>svg]:p-0 data-[control-shape=rounded]:rounded-[calc(var(--input-group-radius)-5px)]', 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', }, }, diff --git a/apps/shade/src/components/ui/select.stories.tsx b/apps/shade/src/components/ui/select.stories.tsx index c7f93b02ef6..56ab1ceb957 100644 --- a/apps/shade/src/components/ui/select.stories.tsx +++ b/apps/shade/src/components/ui/select.stories.tsx @@ -18,7 +18,7 @@ const meta = { docs: { description: { component: - 'Dropdown selection component built on Radix UI. Provides accessible keyboard navigation, search, and customizable styling. Ghost and secondary pill triggers share the button inset shadow while pressed and while their list is open. The open appearance follows aria-expanded and resets on selection or dismissal.', + 'Dropdown selection component built on Radix UI. Form selects use standard rounded corners by default. Use PageHeader.SelectTrigger for pill-shaped header selectors. Provides accessible keyboard navigation, search, and customizable styling. Ghost and secondary pill triggers share the button inset shadow while pressed and while their list is open. The open appearance follows aria-expanded and resets on selection or dismissal.', }, }, }, @@ -61,7 +61,7 @@ export const Default: Story = { export const Pill: Story = { render: () => ( - + @@ -105,7 +105,7 @@ export const GhostPill: Story = { export const SecondaryPill: Story = { render: () => (