diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2c0dec8ada..9b1889f04d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1491,6 +1491,7 @@ jobs: job_build_e2e_public_apps, job_build_e2e_image, job_e2e_tests, + build_packages, publish_packages ] if: always() @@ -1504,15 +1505,73 @@ jobs: run: | echo "One of the dependent jobs have failed or been cancelled. You may need to re-run it." && exit 1 - publish_packages: + # Build verification for @tryghost/* public apps. + # Runs on PRs and pushes. Intentionally has no `id-token: write` — PR-controlled + # code (nx build) must never execute in a job that can mint an npm OIDC token. + # The privileged publish flow lives in publish_packages below, gated to main pushes. + build_packages: needs: [ job_setup, job_lint, job_unit-tests ] - name: Publish ${{ matrix.package_name }} + name: Build ${{ matrix.package_name }} runs-on: ubuntu-latest if: always() && github.repository == 'TryGhost/Ghost' && needs.job_setup.result == 'success' && needs.job_lint.result == 'success' && needs.job_unit-tests.result == 'success' + permissions: + contents: read + strategy: + matrix: + include: + - package_name: '@tryghost/activitypub' + package_path: 'apps/activitypub' + - package_name: '@tryghost/portal' + package_path: 'apps/portal' + - package_name: '@tryghost/sodo-search' + package_path: 'apps/sodo-search' + - package_name: '@tryghost/comments-ui' + package_path: 'apps/comments-ui' + - package_name: '@tryghost/signup-form' + package_path: 'apps/signup-form' + - package_name: '@tryghost/announcement-bar' + package_path: 'apps/announcement-bar' + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + - name: Set up Node.js + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build the package + run: pnpm nx build ${{ matrix.package_name }} + + # Publishes @tryghost/* public apps to npm via OIDC trusted publishing. + # Runs only on push-to-main — never on pull_request — so the `id-token: write` + # permission is never exposed to PR-controlled code (ref: ONC-1677). + publish_packages: + needs: [ + job_setup, + job_lint, + job_unit-tests, + build_packages + ] + name: Publish ${{ matrix.package_name }} + runs-on: ubuntu-latest + if: | + github.event_name != 'pull_request' + && github.repository == 'TryGhost/Ghost' + && needs.job_setup.outputs.is_main == 'true' + && needs.job_setup.result == 'success' + && needs.job_lint.result == 'success' + && needs.job_unit-tests.result == 'success' + && needs.build_packages.result == 'success' permissions: id-token: write strategy: @@ -1553,7 +1612,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Check if version changed - if: needs.job_setup.outputs.is_main == 'true' id: version_check working-directory: ${{ matrix.package_path }} run: | @@ -1578,28 +1636,28 @@ jobs: fi - name: Build the package - if: steps.version_check.outputs.version_changed == 'true' || github.event_name == 'pull_request' + if: steps.version_check.outputs.version_changed == 'true' run: pnpm nx build ${{ matrix.package_name }} - name: Configure .npmrc - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' run: | echo "@tryghost:registry=https://registry.npmjs.org/" >> ~/.npmrc # TODO: Check we can remove this once we update Node to v24 - name: Install v11 of NPM # We need this to install packages via OIDC. - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' run: npm install -g npm@11 - name: Publish to npm - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' working-directory: ${{ matrix.package_path }} run: | npm publish --access public - name: Replace version placeholders in cdn-paths id: cdn_paths - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' run: | cdn_paths="${{ matrix.cdn_paths }}" echo "cdn_paths<> $GITHUB_OUTPUT @@ -1607,17 +1665,17 @@ jobs: echo "EOF" >> $GITHUB_OUTPUT - name: Print cdn_paths - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' run: echo "${{ steps.cdn_paths.outputs.cdn_paths }}" - name: Wait before purging jsDelivr cache - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' && matrix.package_name == '@tryghost/activitypub' + if: steps.version_check.outputs.version_changed == 'true' && matrix.package_name == '@tryghost/activitypub' run: | echo "Purging jsDelivr cache immediately after publishing a new version on NPM is unreliable. Waiting 1 minute before purging cache..." sleep 60 - name: Purge jsDelivr cache - if: needs.job_setup.outputs.is_main == 'true' && steps.version_check.outputs.version_changed == 'true' + if: steps.version_check.outputs.version_changed == 'true' uses: gacts/purge-jsdelivr-cache@8d92aea944f1a3e8ad70505379e1a8ac72d56b73 # v1 with: url: ${{ steps.cdn_paths.outputs.cdn_paths }} diff --git a/apps/admin-x-design-system/src/assets/icons/beehiiv.svg b/apps/admin-x-design-system/src/assets/icons/beehiiv.svg new file mode 100644 index 00000000000..f3bbb1457a8 --- /dev/null +++ b/apps/admin-x-design-system/src/assets/icons/beehiiv.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx index c32cb323c47..d29d0062a52 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/advanced-settings.tsx @@ -9,7 +9,7 @@ import SearchableSection from '../../searchable-section'; export const searchKeywords = { integrations: ['advanced', 'integrations', 'zapier', 'slack', 'unsplash', 'first promoter', 'firstpromoter', 'pintura', 'disqus', 'analytics', 'ulysses', 'typeform', 'buffer', 'plausible', 'github', 'webhooks'], - migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace'], + migrationtools: ['import', 'export', 'migrate', 'substack', 'substack', 'migration', 'medium', 'wordpress', 'wp', 'squarespace', 'beehiiv'], codeInjection: ['advanced', 'code injection', 'head', 'footer'], labs: ['advanced', 'labs', 'alpha', 'private', 'beta', 'flag', 'routes', 'redirect', 'translation', 'editor', 'portal'], history: ['advanced', 'history', 'log', 'events', 'user events', 'staff', 'audit', 'action'], diff --git a/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx b/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx index 66696dafa63..e477958d861 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/migration-tools/migration-tools-import.tsx @@ -45,6 +45,13 @@ const MigrationToolsImport: React.FC = () => { title='Substack' onClick={() => updateRoute({isExternal: true, route: '/migrate/substack'})} /> + + } + title='beehiiv' + onClick={() => updateRoute({isExternal: true, route: '/migrate/beehiiv'})} + /> diff --git a/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx index 222e1d10b56..a32cc15fe23 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx +++ b/apps/admin-x-settings/src/components/settings/email-design/color-picker-field.tsx @@ -1,4 +1,4 @@ -import React, {useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ColorPicker} from '@tryghost/shade/patterns'; import {Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; @@ -59,16 +59,62 @@ const ColorPickerField: React.FC = ({title, value, onChan const normalizedValue = normalizeColorValue(value, accentColor, swatches); const allowPickerChanges = useRef(false); const suppressPickerChanges = useRef(false); + const earlyEscapeHandler = useRef<((event: KeyboardEvent) => void) | null>(null); const selectedSwatch = swatches.find((swatch) => { return swatch.value === value || (value && swatch.hex.toLowerCase() === value.toLowerCase()); }); + const resetInteractionState = () => { + allowPickerChanges.current = false; + suppressPickerChanges.current = false; + }; + const detachEarlyEscapeListener = useCallback(() => { + if (!earlyEscapeHandler.current) { + return; + } + + window.removeEventListener('keydown', earlyEscapeHandler.current, true); + earlyEscapeHandler.current = null; + }, []); + const closePopover = () => { + detachEarlyEscapeListener(); + resetInteractionState(); + setOpen(false); + }; + const attachEarlyEscapeListener = () => { + if (earlyEscapeHandler.current) { + return; + } + + const handleEarlyEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + closePopover(); + }; + + earlyEscapeHandler.current = handleEarlyEscape; + window.addEventListener('keydown', handleEarlyEscape, true); + }; + + useEffect(() => { + return () => { + detachEarlyEscapeListener(); + }; + }, [detachEarlyEscapeListener]); return ( { - allowPickerChanges.current = false; - suppressPickerChanges.current = false; + resetInteractionState(); + if (nextOpen) { + attachEarlyEscapeListener(); + } else { + detachEarlyEscapeListener(); + } setOpen(nextOpen); }} > @@ -94,7 +140,7 @@ const ColorPickerField: React.FC = ({title, value, onChan allowPickerChanges.current = false; suppressPickerChanges.current = true; onChange(swatchValue); - setOpen(false); + closePopover(); }} > {isTransparent(swatch.hex) && } diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-fields/index.ts b/apps/admin-x-settings/src/components/settings/email-design/design-fields/index.ts index 090b0ef4893..d60cf582b53 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/design-fields/index.ts +++ b/apps/admin-x-settings/src/components/settings/email-design/design-fields/index.ts @@ -11,4 +11,3 @@ export {ImageCornersField} from './image-corners-field'; export {LinkColorField} from './link-color-field'; export {LinkStyleField} from './link-style-field'; export {SectionTitleColorField} from './section-title-color-field'; -export {TitleAlignmentField} from './title-alignment-field'; diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-fields/title-alignment-field.tsx b/apps/admin-x-settings/src/components/settings/email-design/design-fields/title-alignment-field.tsx deleted file mode 100644 index 7a911013c0d..00000000000 --- a/apps/admin-x-settings/src/components/settings/email-design/design-fields/title-alignment-field.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import {AlignCenter, AlignLeft} from 'lucide-react'; -import {ToggleGroup, ToggleGroupItem} from '@tryghost/shade/components'; -import {useEmailDesign} from '../email-design-context'; - -export const TitleAlignmentField = () => { - const {settings, onSettingsChange} = useEmailDesign(); - return ( -
- Title alignment - value && onSettingsChange({title_alignment: value})}> - - - -
- ); -}; diff --git a/apps/admin-x-settings/src/components/settings/email-design/design-utils.ts b/apps/admin-x-settings/src/components/settings/email-design/design-utils.ts index 340e8f5fe7d..50fb5599edd 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/design-utils.ts +++ b/apps/admin-x-settings/src/components/settings/email-design/design-utils.ts @@ -4,7 +4,6 @@ import type {EmailDesignSettings} from './types'; export interface ResolvedEmailColors { backgroundColor: string; headerBackgroundColor: string; - postTitleColor: string; sectionTitleColor: string; buttonColor: string; buttonTextColor: string | undefined; @@ -66,17 +65,6 @@ function resolveLinkColor(value: string | null | undefined, accentColor: string, return textColorForBackgroundColor(bgColor).hex(); } -function resolvePostTitleColor(value: string | null, accentColor: string, bgColor: string, headerBgColor: string): string { - if (VALID_HEX.test(value || '')) { - return value!; - } - if (value === 'accent') { - return accentColor; - } - const effectiveBg = headerBgColor === 'transparent' ? bgColor : headerBgColor; - return textColorForBackgroundColor(effectiveBg).hex(); -} - function resolveSectionTitleColor(value: string | null, accentColor: string, bgColor: string): string { if (VALID_HEX.test(value || '')) { return value!; @@ -110,7 +98,6 @@ export function resolveAllColors(settings: EmailDesignSettings, accentColor: str return { backgroundColor: bgColor, headerBackgroundColor: headerBgColor, - postTitleColor: resolvePostTitleColor(settings.post_title_color, accentColor, bgColor, headerBgColor), sectionTitleColor: resolveSectionTitleColor(settings.section_title_color, accentColor, bgColor), buttonColor, buttonTextColor: resolveButtonTextColor(settings.button_style, buttonColor), diff --git a/apps/admin-x-settings/src/components/settings/email-design/types.ts b/apps/admin-x-settings/src/components/settings/email-design/types.ts index f7f11bdfa49..d82db3df7e6 100644 --- a/apps/admin-x-settings/src/components/settings/email-design/types.ts +++ b/apps/admin-x-settings/src/components/settings/email-design/types.ts @@ -1,4 +1,4 @@ -export interface PersistedEmailDesignSettings { +export interface EmailDesignSettings { background_color: string; title_font_category: string; title_font_weight: string; @@ -14,13 +14,6 @@ export interface PersistedEmailDesignSettings { divider_color: string | null; } -export interface EmailDesignPreviewSettings { - post_title_color: string | null; - title_alignment: string; -} - -export type EmailDesignSettings = PersistedEmailDesignSettings & EmailDesignPreviewSettings; - export const DEFAULT_EMAIL_DESIGN: EmailDesignSettings = { background_color: 'light', title_font_category: 'sans_serif', @@ -34,7 +27,5 @@ export const DEFAULT_EMAIL_DESIGN: EmailDesignSettings = { link_color: 'accent', link_style: 'underline', image_corners: 'square', - divider_color: null, - post_title_color: null, - title_alignment: 'center' + divider_color: null }; diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx index 7de775cea0b..ce69d89eede 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/welcome-email-customize-modal.tsx @@ -21,7 +21,7 @@ import { LinkStyleField, SectionTitleColorField } from '../../email-design/design-fields'; -import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings, type PersistedEmailDesignSettings} from '../../email-design/types'; +import {DEFAULT_EMAIL_DESIGN, type EmailDesignSettings} from '../../email-design/types'; import {EmailDesignProvider} from '../../email-design/email-design-context'; import {Input, LoadingIndicator, Separator, Switch, Tabs, TabsContent, TabsList, TabsTrigger, Textarea} from '@tryghost/shade/components'; import {WELCOME_EMAIL_SLUGS, type WelcomeEmailType, getDefaultWelcomeEmailValues} from './default-welcome-email-values'; @@ -50,22 +50,9 @@ interface WelcomeEmailCustomizeFormState { } const SAVE_ERROR_TOAST_ID = 'welcome-email-design-save-error'; -const NON_DESIGN_FIELDS = new Set([ - 'id', - 'slug', - 'created_at', - 'updated_at', - 'header_image', - 'show_header_icon', - 'show_header_title', - 'show_badge', - 'footer_content' -]); -const PREVIEW_ONLY_FIELDS = new Set([ - 'post_title_color', - 'title_alignment' -]); +const WELCOME_EMAIL_DESIGN_FIELDS = new Set(Object.keys(DEFAULT_EMAIL_DESIGN)); +const isWelcomeEmailDesignField = (key: string) => WELCOME_EMAIL_DESIGN_FIELDS.has(key); interface GeneralTabProps { generalSettings: GeneralSettings; onGeneralChange: (updates: Partial) => void; @@ -306,29 +293,22 @@ function mapApiToGeneralSettings( } /** - * Maps API response fields to the frontend EmailDesignSettings shape. + * Maps API response fields to the frontend welcome-email design settings shape. * - * @param {PersistedEmailDesignSettings} apiData - The persisted design fields from the API response - * @returns {EmailDesignSettings} Design settings populated from the API response, with local-only preview fields set to defaults + * @param {EmailDesignSettings} apiData - The persisted design fields from the API response + * @returns {EmailDesignSettings} Design settings populated from the API response */ export function mapApiToDesignSettings( - apiData: PersistedEmailDesignSettings + apiData: EmailDesignSettings ): EmailDesignSettings { - const persistedDesign = Object.fromEntries( - Object.entries(apiData).filter(([key]) => !NON_DESIGN_FIELDS.has(key)) - ) as PersistedEmailDesignSettings; - - return { - ...persistedDesign, - // Local-only fields not stored in the backend - post_title_color: DEFAULT_EMAIL_DESIGN.post_title_color, - title_alignment: DEFAULT_EMAIL_DESIGN.title_alignment - }; + return Object.fromEntries( + Object.entries(apiData).filter(([key]) => isWelcomeEmailDesignField(key)) + ) as EmailDesignSettings; } export function buildAutomatedEmailDesignPayload(state: WelcomeEmailCustomizeFormState): EditAutomatedEmailDesign { const persistedDesign = Object.fromEntries( - Object.entries(state.designSettings).filter(([key]) => !PREVIEW_ONLY_FIELDS.has(key) && !NON_DESIGN_FIELDS.has(key)) + Object.entries(state.designSettings).filter(([key]) => isWelcomeEmailDesignField(key)) ); return { diff --git a/apps/admin-x-settings/src/main-content.tsx b/apps/admin-x-settings/src/main-content.tsx index 7d9cf13dbaf..7fc9c1a3ae1 100644 --- a/apps/admin-x-settings/src/main-content.tsx +++ b/apps/admin-x-settings/src/main-content.tsx @@ -10,6 +10,7 @@ import {useGlobalData} from './components/providers/global-data-provider'; import {useRouting} from '@tryghost/admin-x-framework/routing'; const EMPTY_KEYWORDS: string[] = []; +const OPEN_SHADE_MODAL_SELECTOR = ':is([role="dialog"], [role="alertdialog"])[data-state="open"]'; const Page: React.FC<{children: ReactNode}> = ({children}) => { return <> @@ -30,13 +31,21 @@ const MainContent: React.FC = () => { const navigateAway = (escLocation: string) => { window.location.hash = escLocation; }; + const hasOpenModal = () => { + // Legacy admin-x-design-system modals render a dedicated backdrop element. + if (document.getElementById('modal-backdrop')) { + return true; + } + + // Newer Shade/Radix dialogs expose their open state via dialog roles. + return Boolean(document.querySelector(OPEN_SHADE_MODAL_SELECTOR)); + }; useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { // Don't navigate away if a modal is open - let the modal handle ESC - const modalBackdrop = document.getElementById('modal-backdrop'); - if (modalBackdrop) { + if (hasOpenModal()) { return; } diff --git a/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts b/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts index 45c8aa90326..8733faa43e5 100644 --- a/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts +++ b/apps/admin-x-settings/test/acceptance/advanced/migration-tools.test.ts @@ -14,6 +14,7 @@ test.describe('Migration tools', async () => { const migrators = [ {name: 'Substack', route: '/migrate/substack'}, + {name: 'beehiiv', route: '/migrate/beehiiv'}, {name: 'WordPress', route: '/migrate/wordpress'}, {name: 'Medium', route: '/migrate/medium'}, {name: 'Mailchimp', route: '/migrate/mailchimp'} diff --git a/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts b/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts index 2a6ea10305d..ce0b16a773e 100644 --- a/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts +++ b/apps/admin-x-settings/test/unit/email-design/design-payload.test.ts @@ -30,20 +30,18 @@ describe('Welcome email design payload helpers', function () { assert.equal('footer_content' in result, false); }); - it('preserves unexpected persisted design fields when mapping api data', function () { + it('keeps only known welcome email design fields when mapping api data', function () { const apiData = { ...DEFAULT_EMAIL_DESIGN, - post_title_color: undefined, - title_alignment: undefined, custom_future_field: '#123456' }; const result = mapApiToDesignSettings(apiData as never) as unknown as typeof apiData; - assert.equal(result.custom_future_field, '#123456'); + assert.equal('custom_future_field' in result, false); }); - it('preserves unexpected persisted design fields in the save payload while excluding preview-only fields', function () { + it('keeps only known welcome email design fields in the save payload while excluding non-design metadata fields', function () { const state = { designSettings: { ...DEFAULT_EMAIL_DESIGN, @@ -79,13 +77,11 @@ describe('Welcome email design payload helpers', function () { footer_content: string | null; }; - assert.equal(payload.custom_future_field, '#abcdef'); + assert.equal('custom_future_field' in payload, false); assert.equal('id' in payload, false); assert.equal('slug' in payload, false); assert.equal('created_at' in payload, false); assert.equal('updated_at' in payload, false); - assert.equal('post_title_color' in payload, false); - assert.equal('title_alignment' in payload, false); assert.equal(payload.show_header_icon, true); }); }); diff --git a/apps/admin-x-settings/test/unit/membership/welcome-email-customize-modal.test.tsx b/apps/admin-x-settings/test/unit/membership/welcome-email-customize-modal.test.tsx new file mode 100644 index 00000000000..ccffacb9e24 --- /dev/null +++ b/apps/admin-x-settings/test/unit/membership/welcome-email-customize-modal.test.tsx @@ -0,0 +1,100 @@ +import EmailDesignModal from '@src/components/settings/email-design/email-design-modal'; +import React, {useState} from 'react'; +import assert from 'node:assert/strict'; +import {ButtonColorField} from '@src/components/settings/email-design/design-fields/button-color-field'; +import {DEFAULT_EMAIL_DESIGN} from '@src/components/settings/email-design/types'; +import {EmailDesignProvider} from '@src/components/settings/email-design/email-design-context'; +import {act, fireEvent, render, screen} from '@testing-library/react'; + +vi.mock('@tryghost/shade/components', async () => { + const actual = await vi.importActual('@tryghost/shade/components'); + const react = await vi.importActual('react'); + + const DelayedPopoverContent = ({children, ...props}: React.ComponentProps) => { + const [ready, setReady] = react.useState(false); + + react.useEffect(() => { + const timeout = window.setTimeout(() => { + setReady(true); + }, 50); + + return () => window.clearTimeout(timeout); + }, []); + + return ready ? {children} : null; + }; + + return { + ...actual, + PopoverContent: DelayedPopoverContent + }; +}); + +const TestModal = ({onClose}: {onClose?: () => void}) => { + const [open, setOpen] = useState(true); + + return ( + {}} + > + Preview} + sidebar={} + testId="welcome-email-customize-modal" + title="Customize welcome email" + onClose={() => { + onClose?.(); + setOpen(false); + }} + onSave={() => {}} + /> + + ); +}; + +describe('Welcome email customize modal', function () { + afterEach(function () { + vi.useRealTimers(); + }); + + it('keeps the customize modal open when Escape is pressed after the color picker has mounted', async function () { + vi.useFakeTimers(); + const onClose = vi.fn(); + + render(); + + fireEvent.click(screen.getByText('Button color')); + + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + + assert.ok(screen.getByRole('textbox')); + + await act(async () => { + fireEvent.keyDown(document, {key: 'Escape'}); + }); + + assert.equal(onClose.mock.calls.length, 0); + assert.ok(screen.getByTestId('welcome-email-customize-modal')); + }); + + it('keeps the customize modal open when Escape is pressed immediately after opening the color picker', async function () { + vi.useFakeTimers(); + const onClose = vi.fn(); + + render(); + + await act(async () => { + fireEvent.click(screen.getByText('Button color')); + assert.equal(screen.queryByRole('textbox'), null); + fireEvent.keyDown(document, {key: 'Escape'}); + }); + + assert.ok(screen.queryByTestId('welcome-email-customize-modal')); + assert.equal(onClose.mock.calls.length, 0); + }); +}); diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 7980617018d..16bb1c35977 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/comments-ui", - "version": "1.4.8", + "version": "1.4.10", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/package.json b/apps/portal/package.json index 55f15c926d5..0e7c33b7dd3 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.14", + "version": "2.68.17", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/actions.js b/apps/portal/src/actions.js index 35063506f32..91077a89df2 100644 --- a/apps/portal/src/actions.js +++ b/apps/portal/src/actions.js @@ -316,6 +316,20 @@ async function checkoutPlan({data, state, api}) { } } +async function continueGiftSubscription({state, api}) { + try { + await api.member.continueGiftCheckout(); + } catch (e) { + return { + action: 'continueGiftSubscription:failed', + popupNotification: createPopupNotification({ + type: 'continueGiftSubscription:failed', autoHide: false, closeable: true, state, status: 'error', + message: t('Failed to process checkout, please try again') + }) + }; + } +} + async function checkoutGift({data, state, api}) { try { const {tierId, cadence} = data; @@ -790,6 +804,7 @@ const Actions = { editBilling, manageBilling, checkoutPlan, + continueGiftSubscription, checkoutGift, updateNewsletterPreference, showPopupNotification, diff --git a/apps/portal/src/components/pages/AccountHomePage/components/account-main.js b/apps/portal/src/components/pages/AccountHomePage/components/account-main.js index 99e6f7d9659..0eda2b628dd 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/account-main.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/account-main.js @@ -3,6 +3,7 @@ import CloseButton from '../../../common/close-button'; import UserHeader from './user-header'; import AccountWelcome from './account-welcome'; import ContinueSubscriptionButton from './continue-subscription-button'; +import ContinueGiftSubscriptionBanner from './continue-gift-subscription-banner'; import AccountActions from './account-actions'; const AccountMain = () => { @@ -12,6 +13,7 @@ const AccountMain = () => {
+
diff --git a/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js b/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js index 6868fc273bd..5458dbc4f5e 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/account-welcome.js @@ -1,5 +1,5 @@ import AppContext from '../../../../app-context'; -import {getSubscriptionExpiry, getMemberSubscription, hasOnlyFreePlan, isComplimentaryMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; +import {getSubscriptionExpiry, getMemberSubscription, hasOnlyFreePlan, isComplimentaryMember, isGiftMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; import {getDateString} from '../../../../utils/date-time'; import {useContext} from 'react'; @@ -15,14 +15,16 @@ const AccountWelcome = () => { } const subscription = getMemberSubscription({member}); const isComplimentary = isComplimentaryMember({member}); - const isGiftMember = member?.status === 'gift'; if (isComplimentary && !subscription) { return null; } if (subscription) { const currentPeriodEnd = subscription?.current_period_end; const subscriptionExpiry = getSubscriptionExpiry({member}); - if ((isComplimentary || isGiftMember) && subscriptionExpiry) { + if (isGiftMember({member})) { + return null; + } + if (isComplimentary && subscriptionExpiry) { return (

{t(`Your subscription will expire on {expiryDate}`, {expiryDate: subscriptionExpiry})}

diff --git a/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js b/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js new file mode 100644 index 00000000000..2a57838c0f4 --- /dev/null +++ b/apps/portal/src/components/pages/AccountHomePage/components/continue-gift-subscription-banner.js @@ -0,0 +1,44 @@ +import AppContext from '../../../../app-context'; +import ActionButton from '../../../common/action-button'; +import {getSubscriptionExpiry, isGiftMember} from '../../../../utils/helpers'; +import {useContext} from 'react'; + +const ContinueGiftSubscriptionBanner = () => { + const {member, doAction, action, brandColor} = useContext(AppContext); + + if (!isGiftMember({member})) { + return null; + } + + const expiryDate = getSubscriptionExpiry({member}); + if (!expiryDate) { + return null; + } + + const isRunning = action === 'continueGiftSubscription:running'; + + // TODO: Add translation strings once copy has been finalised + /* eslint-disable i18next/no-literal-string */ + return ( +
+
+

+ Your gift subscription ends on {expiryDate}. Continue with a paid subscription to keep reading. Any remaining days will be added as free trial time. +

+ doAction('continueGiftSubscription')} + isRunning={isRunning} + disabled={isRunning} + isPrimary={true} + brandColor={brandColor} + label='Continue subscription' + style={{ + width: '100%' + }} + /> +
+
+ ); +}; + +export default ContinueGiftSubscriptionBanner; diff --git a/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js b/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js index a6e594c58ef..9b7dc75e123 100644 --- a/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js +++ b/apps/portal/src/components/pages/AccountHomePage/components/paid-account-actions.js @@ -1,5 +1,5 @@ import AppContext from '../../../../app-context'; -import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; +import {getSubscriptionExpiry, getMemberSubscription, getMemberTierName, hasMultipleProductsFeature, hasOnlyFreePlan, isComplimentaryMember, isGiftMember, isPaidMember, subscriptionHasFreeTrial} from '../../../../utils/helpers'; import {getDateString} from '../../../../utils/date-time'; import {ReactComponent as GiftIcon} from '../../../../images/icons/gift.svg'; import {ReactComponent as LoaderIcon} from '../../../../images/icons/loader.svg'; @@ -35,9 +35,8 @@ const PaidAccountActions = () => { } const subscriptionExpiry = getSubscriptionExpiry({member}); - const isGiftMember = member?.status === 'gift'; - if (isGiftMember && subscriptionExpiry) { + if (isGiftMember({member}) && subscriptionExpiry) { return (

@@ -101,6 +100,16 @@ const PaidAccountActions = () => { if (hasOnlyFreePlan({site}) && !isPaid) { return null; } + if (isGiftMember({member})) { + return ( + + ); + } return (