diff --git a/.github/actions/load-docker-image/action.yml b/.github/actions/load-docker-image/action.yml index 4a62fb9316d..2ceeade6983 100644 --- a/.github/actions/load-docker-image/action.yml +++ b/.github/actions/load-docker-image/action.yml @@ -7,6 +7,10 @@ inputs: image-tags: description: 'Docker image tags (multi-line string)' required: true + artifact-name: + description: 'Name of the artifact to download (fork PRs only)' + required: false + default: 'docker-image' runs: using: 'composite' @@ -15,14 +19,14 @@ runs: if: inputs.is-fork == 'true' uses: actions/download-artifact@v4 with: - name: docker-image + name: ${{ inputs.artifact-name }} - name: Load image from artifact (fork PR) if: inputs.is-fork == 'true' shell: bash run: | echo "Loading Docker image from artifact..." - gunzip -c docker-image.tar.gz | docker load + gunzip -c ${{ inputs.artifact-name }}.tar.gz | docker load echo "Available images after load:" docker images diff --git a/.github/scripts/setup.js b/.github/scripts/setup.js deleted file mode 100644 index 801002121ae..00000000000 --- a/.github/scripts/setup.js +++ /dev/null @@ -1,143 +0,0 @@ -const {spawn} = require('child_process'); -const fs = require('fs').promises; -const path = require('path'); - -const chalk = require('chalk'); -const inquirer = require('inquirer'); - -/** - * Run a command and stream output to the console - * - * @param {string} command - * @param {string[]} args - * @param {object} options - */ -async function runAndStream(command, args, options) { - return new Promise((resolve, reject) => { - const child = spawn(command, args, { - stdio: 'inherit', - ...options - }); - - child.on('close', (code) => { - if (code === 0) { - resolve(code); - } else { - reject(new Error(`'${command} ${args.join(' ')}' exited with code ${code}`)); - } - }); - - }); -} - -(async () => { - if (process.env.NODE_ENV !== 'development') { - console.log(chalk.yellow(`NODE_ENV is not development, skipping setup`)); - return; - } - - if (process.env.DEVCONTAINER === 'true') { - console.log(chalk.yellow(`Devcontainer detected, skipping setup`)); - return; - } - - const coreFolder = path.join(__dirname, '../../ghost/core'); - const rootFolder = path.join(__dirname, '../..'); - const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ - customConfigPath: coreFolder - }); - - const dbClient = config.get('database:client'); - const isUsingDocker = config.get('database:docker'); - - // Only reset data if we are using Docker - let resetData = false; - - if (!dbClient.includes('mysql')) { - let mysqlSetup = false; - console.log(chalk.blue(`Attempting to setup MySQL via Docker`)); - try { - await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); - mysqlSetup = true; - } catch (err) { - console.error(chalk.red('Failed to run MySQL Docker container'), err); - console.error(chalk.red('Hint: is Docker installed and running?')); - } - - if (mysqlSetup) { - resetData = true; - console.log(chalk.blue(`Adding MySQL credentials to config.local.json`)); - const currentConfigPath = path.join(coreFolder, 'config.local.json'); - - let currentConfig; - try { - currentConfig = require(currentConfigPath); - } catch (err) { - currentConfig = {}; - } - - currentConfig.database = { - client: 'mysql', - docker: true, - connection: { - host: '127.0.0.1', - user: 'root', - password: 'root', - database: 'ghost' - } - }; - - try { - await fs.writeFile(currentConfigPath, JSON.stringify(currentConfig, null, 4)); - } catch (err) { - console.error(chalk.red('Failed to write config.local.json'), err); - console.log(chalk.yellow(`Please add the following to config.local.json:\n`), JSON.stringify(currentConfig, null, 4)); - process.exit(1); - } - } - } else { - if (isUsingDocker) { - const yesAll = process.argv.includes('-y'); - const noAll = process.argv.includes('-n'); - const {confirmed} = - yesAll ? {confirmed: true} - : ( - noAll ? {confirmed: false} - : await inquirer.prompt({name: 'confirmed', type:'confirm', message: 'MySQL is running via Docker, do you want to reset the Docker container? This will delete all existing data.', default: false}) - ); - - if (confirmed) { - console.log(chalk.yellow(`Resetting Docker container`)); - - try { - await runAndStream('yarn', ['docker:reset'], {cwd: path.join(__dirname, '../../')}); - resetData = true; - } catch (err) { - console.error(chalk.red('Failed to run MySQL Docker container'), err); - console.error(chalk.red('Hint: is Docker installed and running?')); - } - } - } else { - console.log(chalk.green(`MySQL already configured locally. Stop your local database and delete your "database" configuration in config.local.json to switch to Docker.`)); - } - } - - console.log(chalk.blue(`Running knex-migrator init`)); - await runAndStream('yarn', ['knex-migrator', 'init'], {cwd: coreFolder}); - if (process.argv.includes('--no-seed')) { - console.log(chalk.yellow(`Skipping seed data`)); - console.log(chalk.yellow(`Done`)); - return; - } - if (resetData) { - const xxl = process.argv.includes('--xxl'); - - if (xxl) { - console.log(chalk.blue(`Resetting all data (with xxl)`)); - await runAndStream('yarn', ['reset:data:xxl'], {cwd: rootFolder}); - } else { - console.log(chalk.blue(`Resetting all data`)); - await runAndStream('yarn', ['reset:data'], {cwd: rootFolder}); - } - } -})(); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba1247b871..bafbfa96c69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1062,6 +1062,142 @@ jobs: image-digest: ${{ steps.build.outputs.digest }} is-fork: ${{ steps.strategy.outputs.is-fork-pr }} + job_docker_build_production: + name: Build & Push Production Docker Images + needs: [job_setup] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-node@v4 + env: + FORCE_COLOR: 0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Restore caches + uses: ./.github/actions/restore-cache + env: + DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} + + - name: Build server and admin assets + run: yarn build:production + + - name: Pack standalone distribution + run: yarn workspace ghost pack:standalone + + - name: Prepare Docker build context + run: mv ghost/core/package/ /tmp/ghost-production/ + + - name: Determine push strategy + id: strategy + run: | + IS_FORK_PR="false" + if [ "${{ github.event_name }}" = "pull_request" ] && \ + [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]; then + IS_FORK_PR="true" + fi + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "is-fork-pr=$IS_FORK_PR" >> $GITHUB_OUTPUT + echo "should-push=$( [ "$IS_FORK_PR" = "false" ] && echo "true" || echo "false" )" >> $GITHUB_OUTPUT + echo "owner=$OWNER" >> $GITHUB_OUTPUT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: steps.strategy.outputs.should-push == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta (core) + id: meta-core + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=latest,enable={{is_default_branch}} + labels: | + org.opencontainers.image.title=Ghost Core + org.opencontainers.image.description=Ghost production build (server only, no admin) + org.opencontainers.image.vendor=TryGhost + + - name: Docker meta (full) + id: meta-full + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ steps.strategy.outputs.owner }}/ghost + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha + type=raw,value=latest,enable={{is_default_branch}} + labels: | + org.opencontainers.image.title=Ghost + org.opencontainers.image.description=Ghost production build (server + admin) + org.opencontainers.image.vendor=TryGhost + + - name: Build & push core image + uses: docker/build-push-action@v6 + with: + context: /tmp/ghost-production + file: Dockerfile.production + target: core + build-args: NODE_VERSION=${{ env.NODE_VERSION }} + push: ${{ steps.strategy.outputs.should-push }} + load: ${{ steps.strategy.outputs.should-push == 'false' }} + tags: ${{ steps.meta-core.outputs.tags }} + labels: ${{ steps.meta-core.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost-core:cache-main + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref=ghcr.io/{0}/ghost-core:cache-{1},mode=max', steps.strategy.outputs.owner, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + + - name: Build & push full image + uses: docker/build-push-action@v6 + with: + context: /tmp/ghost-production + file: Dockerfile.production + target: full + build-args: NODE_VERSION=${{ env.NODE_VERSION }} + push: ${{ steps.strategy.outputs.should-push }} + load: ${{ steps.strategy.outputs.should-push == 'false' }} + tags: ${{ steps.meta-full.outputs.tags }} + labels: ${{ steps.meta-full.outputs.labels }} + cache-from: type=registry,ref=ghcr.io/${{ steps.strategy.outputs.owner }}/ghost:cache-main + cache-to: ${{ steps.strategy.outputs.should-push == 'true' && format('type=registry,ref=ghcr.io/{0}/ghost:cache-{1},mode=max', steps.strategy.outputs.owner, github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main') || '' }} + + - name: Save full image as artifact (fork PR) + if: steps.strategy.outputs.is-fork-pr == 'true' + run: | + IMAGE_TAG=$(echo "${{ steps.meta-full.outputs.tags }}" | head -n1) + echo "Saving image: $IMAGE_TAG" + docker save "$IMAGE_TAG" | gzip > docker-image-production.tar.gz + echo "Image saved as docker-image-production.tar.gz" + ls -lh docker-image-production.tar.gz + + - name: Upload image artifact (fork PR) + if: steps.strategy.outputs.is-fork-pr == 'true' + uses: actions/upload-artifact@v4 + with: + name: docker-image-production + path: docker-image-production.tar.gz + retention-days: 1 + + outputs: + image-tags: ${{ steps.meta-full.outputs.tags }} + is-fork: ${{ steps.strategy.outputs.is-fork-pr }} + job_inspect_image: name: Inspect Docker Image needs: job_docker_build @@ -1132,7 +1268,7 @@ jobs: job_e2e_tests: name: E2E Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) runs-on: ubuntu-latest - needs: [job_docker_build, job_setup] + needs: [job_docker_build_production, job_setup] strategy: fail-fast: true matrix: @@ -1160,17 +1296,13 @@ jobs: uses: ./.github/actions/load-docker-image id: load with: - is-fork: ${{ needs.job_docker_build.outputs.is-fork }} - image-tags: ${{ needs.job_docker_build.outputs.image-tags }} + is-fork: ${{ needs.job_docker_build_production.outputs.is-fork }} + image-tags: ${{ needs.job_docker_build_production.outputs.image-tags }} + artifact-name: docker-image-production - name: Setup Docker Registry Mirrors uses: ./.github/actions/setup-docker-registry-mirrors - - name: Pull images - env: - GHOST_IMAGE_TAG: ${{ steps.load.outputs.image-tag }} - run: docker compose -f e2e/compose.yml pull - - uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} @@ -1180,12 +1312,25 @@ jobs: env: DEPENDENCY_CACHE_KEY: ${{ needs.job_setup.outputs.dependency_cache_key }} + - name: Build public app UMD bundles + run: yarn workspace @tryghost/e2e build:apps + + - name: Build E2E image layer + env: + GHOST_E2E_BASE_IMAGE: ${{ steps.load.outputs.image-tag }} + run: yarn workspace @tryghost/e2e build:docker + + - name: Pull images + env: + GHOST_E2E_IMAGE: ghost-e2e:local + run: docker compose -f e2e/compose.yml pull + - name: Setup Playwright uses: ./.github/actions/setup-playwright - name: Run e2e tests env: - GHOST_IMAGE_TAG: ${{ steps.load.outputs.image-tag }} + GHOST_E2E_IMAGE: ghost-e2e:local TEST_WORKERS_COUNT: 1 run: yarn test:e2e:all --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --retries=2 diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 00000000000..1d63b311aaf --- /dev/null +++ b/Dockerfile.production @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1-labs + +# Production Dockerfile for Ghost +# Two targets: +# core — server + production deps, no admin (Ghost-Pro base) +# full — core + built admin (self-hosting) +# +# Build context: extracted `npm pack` output from ghost/core + +ARG NODE_VERSION=22.18.0 + +# ---- Core: server + production deps ---- +FROM node:$NODE_VERSION-bookworm-slim AS core + +ENV NODE_ENV=production + +RUN apt-get update && \ + apt-get install -y --no-install-recommends libjemalloc2 && \ + rm -rf /var/lib/apt/lists/* && \ + groupmod -g 1001 node && \ + usermod -u 1001 node && \ + adduser --disabled-password --gecos "" -u 1000 ghost + +WORKDIR /home/ghost + +COPY --exclude=core/built/admin . . + +RUN --mount=type=cache,target=/usr/local/share/.cache/yarn/v6 \ + apt-get update && \ + apt-get install -y --no-install-recommends build-essential python3 && \ + yarn install --ignore-scripts --production --prefer-offline && \ + (cd node_modules/sqlite3 && npm run install) && \ + apt-get purge -y build-essential python3 && \ + apt-get autoremove -y && \ + rm -rf /var/lib/apt/lists/* && \ + mkdir -p default log && \ + cp -R content base_content && \ + cp -R content/themes/casper default/casper && \ + ([ -d content/themes/source ] && cp -R content/themes/source default/source || true) && \ + chown ghost:ghost /home/ghost && \ + chown -R nobody:nogroup /home/ghost/* && \ + chown -R ghost:ghost /home/ghost/content /home/ghost/log + +USER ghost +ENV LD_PRELOAD=libjemalloc.so.2 + +EXPOSE 2368 + +CMD ["node", "index.js"] + +# ---- Full: core + admin ---- +FROM core AS full + +USER root +COPY core/built/admin core/built/admin +RUN chown -R nobody:nogroup core/built/admin +USER ghost diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index 3279ac2b95d..1ee3f656240 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -51,10 +51,6 @@ const features: Feature[] = [{ title: 'Transistor', description: 'Enable Transistor podcast integration', flag: 'transistor' -}, { - title: 'Inbox Links', - description: 'Enable mail app links on signup/signin', - flag: 'inboxlinks' }, { title: 'Retention Offers', description: 'Enable retention offers for canceling members', diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx index 9caf92aee94..c229e1613f8 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/edit-retention-offer-modal.tsx @@ -5,6 +5,7 @@ import {type ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Form, PreviewModalContent, Select, type SelectOption, TextArea, TextField, Toggle, showToast} from '@tryghost/admin-x-design-system'; import {JSONError} from '@tryghost/admin-x-framework/errors'; import {type Offer, useAddOffer, useBrowseOffers, useEditOffer, useInvalidateOffers} from '@tryghost/admin-x-framework/api/offers'; +import {createOfferRedemptionsFilterUrl, formatOfferTimestamp} from './offer-helpers'; import {getOfferPortalPreviewUrl, type offerPortalPreviewUrlTypes} from '../../../../utils/get-offers-portal-preview-url'; import {getPaidActiveTiers, useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; import {useEffect, useMemo, useState} from 'react'; @@ -176,8 +177,10 @@ const RetentionOfferSidebar: React.FC<{ clearError: (field: string) => void; errors: ErrorMessages; cadence: 'monthly' | 'yearly'; + lastRedeemed?: string | null; + membersFilterUrl?: string | null; redemptions: number; -}> = ({formState, updateForm, clearError, errors, cadence, redemptions}) => { +}> = ({formState, updateForm, clearError, errors, cadence, lastRedeemed, membersFilterUrl, redemptions}) => { const availableDurationOptions = cadence === 'yearly' ? durationOptions.filter(option => option.value !== 'repeating') : durationOptions; @@ -188,8 +191,22 @@ const RetentionOfferSidebar: React.FC<{ - Performance - {redemptions} redemptions + + + + Performance + {redemptions} {redemptions === 1 ? 'redemption' : 'redemptions'} + + {redemptions > 0 && lastRedeemed ? + + Last redemption + {formatOfferTimestamp(lastRedeemed)} + : + null + } + + {redemptions > 0 && membersFilterUrl ? See members → : null} + @@ -358,6 +375,24 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { return total + (offer.redemption_count || 0); }, 0); }, [retentionOffersByCadence]); + const retentionOfferIdsByCadence = useMemo(() => { + return retentionOffersByCadence.map(offer => offer.id); + }, [retentionOffersByCadence]); + const latestRetentionRedemption = useMemo(() => { + return retentionOffersByCadence + .map(offer => offer.last_redeemed) + .filter((lastRedeemed): lastRedeemed is string => !!lastRedeemed) + .sort((left, right) => { + return new Date(right).getTime() - new Date(left).getTime(); + })[0] || null; + }, [retentionOffersByCadence]); + const retentionMembersFilterUrl = useMemo(() => { + if (retentionRedemptions === 0 || retentionOfferIdsByCadence.length === 0) { + return null; + } + + return createOfferRedemptionsFilterUrl(retentionOfferIdsByCadence); + }, [retentionOfferIdsByCadence, retentionRedemptions]); const [lastPreviewPercentAmount, setLastPreviewPercentAmount] = useState(20); const [lastPreviewFreeMonths, setLastPreviewFreeMonths] = useState(1); const [initializedOfferKey, setInitializedOfferKey] = useState(null); @@ -525,6 +560,8 @@ const EditRetentionOfferModal: React.FC<{id: string}> = ({id}) => { clearError={clearError} errors={errors} formState={formState} + lastRedeemed={latestRetentionRedemption} + membersFilterUrl={retentionMembersFilterUrl} redemptions={retentionRedemptions} updateForm={updateForm} /> diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/offer-helpers.ts b/apps/admin-x-settings/src/components/settings/growth/offers/offer-helpers.ts new file mode 100644 index 00000000000..8338dbdcf56 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/growth/offers/offer-helpers.ts @@ -0,0 +1,18 @@ +export const formatOfferTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + return date.toLocaleDateString('default', { + year: 'numeric', + month: 'short', + day: '2-digit' + }); +}; + +export const createOfferRedemptionsFilterUrl = (offerIds: string[]): string => { + const baseHref = '/ghost/#/members'; + const filterValue = `offer_redemptions:[${offerIds.join(',')}]`; + return `${baseHref}?filter=${encodeURIComponent(filterValue)}`; +}; + +export const createOfferRedemptionFilterUrl = (offerId: string): string => { + return createOfferRedemptionsFilterUrl([offerId]); +}; diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx index 1e18a6419f9..1f2019ebe47 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/offers-retention.tsx @@ -1,4 +1,5 @@ import {type Offer, useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; +import {createOfferRedemptionsFilterUrl} from './offer-helpers'; import {useRouting} from '@tryghost/admin-x-framework/routing'; type RetentionCadence = 'month' | 'year'; @@ -9,6 +10,7 @@ type RetentionOffer = { description: string; terms: string | null; // e.g. "50% OFF" when active termsDetail: string | null; // e.g. "Next payment" when active + redemptionOfferIds: string[]; redemptions: number; status: 'active' | 'inactive'; }; @@ -31,6 +33,14 @@ const getRetentionRedemptionsByCadence = (offers: Offer[], cadence: RetentionCad }, 0); }; +const getRetentionOfferIdsByCadence = (offers: Offer[], cadence: RetentionCadence): string[] => { + return offers + .filter((offer) => { + return offer.redemption_type === 'retention' && offer.cadence === cadence; + }) + .map(offer => offer.id); +}; + const getRetentionTerms = (offer: Offer | null): string | null => { if (!offer) { return null; @@ -76,6 +86,8 @@ const getRetentionTermsDetail = (offer: Offer | null): string | null => { const getRetentionOffers = (offers: Offer[]): RetentionOffer[] => { const monthlyOffer = getActiveRetentionOfferByCadence(offers, 'month'); const yearlyOffer = getActiveRetentionOfferByCadence(offers, 'year'); + const monthlyOfferIds = getRetentionOfferIdsByCadence(offers, 'month'); + const yearlyOfferIds = getRetentionOfferIdsByCadence(offers, 'year'); const monthlyRedemptions = getRetentionRedemptionsByCadence(offers, 'month'); const yearlyRedemptions = getRetentionRedemptionsByCadence(offers, 'year'); @@ -86,6 +98,7 @@ const getRetentionOffers = (offers: Offer[]): RetentionOffer[] => { description: 'Applied to monthly plans', terms: getRetentionTerms(monthlyOffer), termsDetail: getRetentionTermsDetail(monthlyOffer), + redemptionOfferIds: monthlyOfferIds, redemptions: monthlyRedemptions, status: monthlyOffer ? 'active' : 'inactive' }, @@ -95,6 +108,7 @@ const getRetentionOffers = (offers: Offer[]): RetentionOffer[] => { description: 'Applied to annual plans', terms: getRetentionTerms(yearlyOffer), termsDetail: getRetentionTermsDetail(yearlyOffer), + redemptionOfferIds: yearlyOfferIds, redemptions: yearlyRedemptions, status: yearlyOffer ? 'active' : 'inactive' } @@ -106,7 +120,7 @@ const OffersRetention: React.FC = () => { const {data: {offers: allOffers = []} = {}} = useBrowseOffers(); const retentionOffers = getRetentionOffers(allOffers); - const handleRetentionOfferClick = (id: string) => { + const handleOfferEdit = (id: string) => { updateRoute(`offers/edit/retention/${id}`); }; @@ -120,43 +134,54 @@ const OffersRetention: React.FC = () => { - {retentionOffers.map(offer => ( - - - handleRetentionOfferClick(offer.id)}> - {offer.name} - {offer.description} - - - - handleRetentionOfferClick(offer.id)}> - {offer.terms ? ( - <> - {offer.terms} - {offer.termsDetail} - > - ) : ( - – - )} - - - - handleRetentionOfferClick(offer.id)}> - {offer.redemptions} - - - - handleRetentionOfferClick(offer.id)}> - {offer.status === 'active' ? ( - Active - ) : ( - Inactive - )} - - - - - ))} + {retentionOffers.map((offer) => { + const redemptionFilterUrl = offer.redemptions > 0 && offer.redemptionOfferIds.length > 0 + ? createOfferRedemptionsFilterUrl(offer.redemptionOfferIds) + : undefined; + + return ( + + + handleOfferEdit(offer.id)}> + {offer.name} + {offer.description} + + + + handleOfferEdit(offer.id)}> + {offer.terms ? ( + <> + {offer.terms} + {offer.termsDetail} + > + ) : ( + – + )} + + + + handleOfferEdit(offer.id) : undefined} + > + {offer.redemptions} + + + + handleOfferEdit(offer.id)}> + {offer.status === 'active' ? ( + Active + ) : ( + Inactive + )} + + + + + ); + })} ); diff --git a/apps/admin-x-settings/test/acceptance/membership/offers.test.ts b/apps/admin-x-settings/test/acceptance/membership/offers.test.ts index 58f55a3fc84..0c5569f0b6e 100644 --- a/apps/admin-x-settings/test/acceptance/membership/offers.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/offers.test.ts @@ -283,6 +283,8 @@ test.describe('Offers Modal', () => { redemption_count: number; redemption_type: 'retention'; tier: null; + created_at?: string; + last_redeemed?: string; }; const signupOffers = (responseFixtures.offers.offers || []).filter(offer => offer.redemption_type === 'signup'); @@ -365,6 +367,16 @@ test.describe('Offers Modal', () => { return {modal, retentionModal}; }; + const formatOfferDateForBrowser = async (page: Page, timestamp: string) => { + return await page.evaluate((value) => { + return new Date(value).toLocaleDateString('default', { + year: 'numeric', + month: 'short', + day: '2-digit' + }); + }, timestamp); + }; + test('Lists monthly and yearly retention offers', async ({page}) => { await mockApi({page, requests: getRetentionRequests({ retentionOffers: [ @@ -410,11 +422,13 @@ test.describe('Offers Modal', () => { await expect(monthlyRow).toContainText('First payment'); await expect(monthlyRow).toContainText('10'); await expect(monthlyRow).toContainText('Active'); + await expect(monthlyRow.getByTestId('retention-redemptions-link-monthly')).toHaveAttribute('href', '/ghost/#/members?filter=offer_redemptions%3A%5Bretention-month-active%2Cretention-month-archived%5D'); const yearlyRow = rows.nth(1); await expect(yearlyRow).toContainText('Yearly retention'); await expect(yearlyRow).toContainText('Inactive'); await expect(yearlyRow).toContainText('9'); + await expect(yearlyRow.getByTestId('retention-redemptions-link-yearly')).toHaveAttribute('href', '/ghost/#/members?filter=offer_redemptions%3A%5Bretention-year-archived%5D'); await expect(yearlyRow).not.toContainText('2 MONTHS FREE'); }); @@ -430,7 +444,9 @@ test.describe('Offers Modal', () => { amount: 40, duration: 'repeating', duration_in_months: 3, - redemption_count: 7 + redemption_count: 7, + created_at: '2026-02-17T12:00:00.000Z', + last_redeemed: '2026-02-18T12:00:00.000Z' }), createRetentionOffer({ id: 'retention-year-active', @@ -442,7 +458,9 @@ test.describe('Offers Modal', () => { cadence: 'year', amount: 2, duration: 'free_months', - redemption_count: 4 + redemption_count: 4, + created_at: '2026-02-16T12:00:00.000Z', + last_redeemed: '2026-02-17T12:00:00.000Z' }), createRetentionOffer({ id: 'retention-month-archived', @@ -452,7 +470,9 @@ test.describe('Offers Modal', () => { amount: 30, duration: 'once', status: 'archived', - redemption_count: 4 + redemption_count: 4, + created_at: '2026-01-19T12:00:00.000Z', + last_redeemed: '2026-02-19T12:00:00.000Z' }), createRetentionOffer({ id: 'retention-year-archived', @@ -464,13 +484,19 @@ test.describe('Offers Modal', () => { amount: 1, duration: 'free_months', status: 'archived', - redemption_count: 5 + redemption_count: 5, + created_at: '2026-01-25T12:00:00.000Z', + last_redeemed: '2026-02-18T12:00:00.000Z' }) ] })}); const {modal, retentionModal: monthlyModal} = await openRetentionModal(page, 'Monthly retention'); + const expectedMonthlyLastRedemption = await formatOfferDateForBrowser(page, '2026-02-19T12:00:00.000Z'); await expect(monthlyModal).toContainText('11 redemptions'); + await expect(monthlyModal).toContainText('Last redemption'); + await expect(monthlyModal).toContainText(expectedMonthlyLastRedemption); + await expect(monthlyModal.getByRole('link', {name: 'See members →'})).toHaveAttribute('href', /offer_redemptions%3A%5Bretention-month-active%2Cretention-month-archived%5D/); await expect(monthlyModal.getByLabel('Enable monthly retention')).toBeChecked(); await expect(monthlyModal.getByLabel('Display title')).toHaveValue('Stay monthly'); await expect(monthlyModal.getByLabel('Display description')).toHaveValue('Monthly description'); @@ -481,8 +507,12 @@ test.describe('Offers Modal', () => { await modal.getByText('Yearly retention').click(); const yearlyModal = page.getByTestId('retention-offer-modal'); + const expectedYearlyLastRedemption = await formatOfferDateForBrowser(page, '2026-02-18T12:00:00.000Z'); await expect(yearlyModal).toBeVisible(); await expect(yearlyModal).toContainText('9 redemptions'); + await expect(yearlyModal).toContainText('Last redemption'); + await expect(yearlyModal).toContainText(expectedYearlyLastRedemption); + await expect(yearlyModal.getByRole('link', {name: 'See members →'})).toHaveAttribute('href', /offer_redemptions%3A%5Bretention-year-active%2Cretention-year-archived%5D/); await expect(yearlyModal.getByLabel('Enable yearly retention')).toBeChecked(); await expect(yearlyModal.getByLabel('Display title')).toHaveValue('Stay yearly'); await expect(yearlyModal.getByLabel('Display description')).toHaveValue('Yearly description'); diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css index 1c695536add..b7256dc4d82 100644 --- a/apps/admin/src/index.css +++ b/apps/admin/src/index.css @@ -40,18 +40,6 @@ body.react-admin #ember-app { height: 100%; } -/* Override Ember's fixed positioning to work within our flex layout */ -body.react-admin #ember-app .gh-app { - position: static; - width: 100%; - height: 100%; -} - -/* Remove Ember's overflow since we handle scrolling at the SidebarInset level */ -body.react-admin .gh-main { - overflow-y: visible; -} - /* Temporary overrides until Ember transition is finished */ body.react-admin .gh-canvas-header { padding-top: 24px; diff --git a/apps/admin/src/layout/admin-layout.tsx b/apps/admin/src/layout/admin-layout.tsx index 73687ffb3d6..5cf8878bcf7 100644 --- a/apps/admin/src/layout/admin-layout.tsx +++ b/apps/admin/src/layout/admin-layout.tsx @@ -34,7 +34,7 @@ export function AdminLayout({ children }: AdminLayoutProps) { return ( - + {children} diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-banner.test.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-banner.test.tsx new file mode 100644 index 00000000000..0337f02cf9d --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-banner.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import AppSidebarBanner from "./app-sidebar-banner"; +import type { SidebarBannerState } from "./hooks/use-sidebar-banner-state"; + +const mockUseSidebarBannerState = vi.fn<() => SidebarBannerState>(() => ({ + bannerType: null, + banner: null, + hasBanner: false +})); + +vi.mock("./hooks/use-sidebar-banner-state", () => ({ + useSidebarBannerState: () => mockUseSidebarBannerState() +})); + +describe("AppSidebarBanner", () => { + beforeEach(() => { + mockUseSidebarBannerState.mockReturnValue({ + bannerType: null, + banner: null, + hasBanner: false + }); + }); + + test("does not render when no banner is available", () => { + const {container} = render(); + expect(container).toBeEmptyDOMElement(); + }); + + test("renders banner from shared state hook", () => { + mockUseSidebarBannerState.mockReturnValue({ + bannerType: 'theme-errors', + banner: Theme Error Banner, + hasBanner: true + }); + + render(); + + expect(screen.getByText("Theme Error Banner")).toBeInTheDocument(); + }); + + test("renders banner prop when explicitly provided", () => { + mockUseSidebarBannerState.mockReturnValue({ + bannerType: 'theme-errors', + banner: Hook Banner, + hasBanner: true + }); + + render(Provided Banner} />); + + expect(screen.getByText("Provided Banner")).toBeInTheDocument(); + expect(screen.queryByText("Hook Banner")).not.toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-banner.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-banner.tsx new file mode 100644 index 00000000000..9d96065372c --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-banner.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +import { useSidebarBannerState } from "./hooks/use-sidebar-banner-state"; + +interface AppSidebarBannerProps { + banner?: ReactNode; +} + +function AppSidebarBanner({banner}: AppSidebarBannerProps) { + const sidebarBannerState = useSidebarBannerState(); + const resolvedBanner = banner ?? sidebarBannerState.banner; + + if (!resolvedBanner) { + return null; + } + + return ( + + {resolvedBanner} + + ); +} + +export default AppSidebarBanner; diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-content.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-content.tsx index 8800b271706..381812e97c4 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar-content.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-content.tsx @@ -1,53 +1,35 @@ import { SidebarContent, } from "@tryghost/shade" -import type { ReactNode } from "react"; - -import WhatsNewBanner from "@/whats-new/components/whats-new-banner"; +import AppSidebarBanner from "./app-sidebar-banner"; import NavMain from "./nav-main"; import NavContent from "./nav-content"; import NavGhostPro from "./nav-ghost-pro"; import NavSettings from "./nav-settings"; -import ThemeErrorsBanner from "./theme-errors-banner"; -import UpgradeBanner from "./upgrade-banner"; -import { useUpgradeStatus } from "./hooks/use-upgrade-status"; -import { useWhatsNewStatus } from "./hooks/use-whats-new-status"; -import { useActiveThemeErrors } from "./hooks/use-theme-errors"; +import { useSidebarBannerState } from "./hooks/use-sidebar-banner-state"; function AppSidebarContent() { - const {hasErrors} = useActiveThemeErrors(); - const { showUpgradeBanner, trialDaysRemaining } = useUpgradeStatus(); - const { showWhatsNewBanner } = useWhatsNewStatus(); - let banner: ReactNode = null; + const {banner, bannerType} = useSidebarBannerState(); let bannerContainerClassName = ''; - if (hasErrors) { - banner = ; + if (bannerType === 'theme-errors') { bannerContainerClassName = 'pb-[110px]'; - } else { - if (showUpgradeBanner) { - banner = ; - bannerContainerClassName = 'pb-[254px]'; - } else if (showWhatsNewBanner) { - banner = ; - bannerContainerClassName = 'pb-[180px]'; - } + } else if (bannerType === 'upgrade') { + bannerContainerClassName = 'pb-[254px]'; + } else if (bannerType === 'whats-new') { + bannerContainerClassName = 'pb-[180px]'; } return ( - + - {banner && - - {banner} - - } + diff --git a/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx b/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx index 45047f68dfd..e8ad9e23248 100644 --- a/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx +++ b/apps/admin/src/layout/app-sidebar/app-sidebar-footer.tsx @@ -8,21 +8,16 @@ import { } from "@tryghost/shade" import WhatsNewDialog from "@/whats-new/components/whats-new-dialog"; import { UserMenu } from "./user-menu"; -import { useUpgradeStatus } from "./hooks/use-upgrade-status"; -import { useWhatsNewStatus } from "./hooks/use-whats-new-status"; -import { useActiveThemeErrors } from "./hooks/use-theme-errors"; +import { useSidebarBannerState } from "./hooks/use-sidebar-banner-state"; function AppSidebarFooter({ ...props }: React.ComponentProps) { const [isWhatsNewDialogOpen, setIsWhatsNewDialogOpen] = useState(false); - const { showUpgradeBanner } = useUpgradeStatus(); - const { showWhatsNewBanner } = useWhatsNewStatus(); - const {hasErrors} = useActiveThemeErrors(); - const banner = showUpgradeBanner || showWhatsNewBanner || hasErrors; + const {hasBanner} = useSidebarBannerState(); return ( <> - + setIsWhatsNewDialogOpen(true)} /> diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx new file mode 100644 index 00000000000..8a9385d2adb --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.test.tsx @@ -0,0 +1,88 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { useSidebarBannerState } from "./use-sidebar-banner-state"; + +const mockUseSidebarVisibility = vi.fn<() => boolean>(() => true); +const mockUseActiveThemeErrors = vi.fn<() => {hasErrors: boolean}>(() => ({hasErrors: false})); +const mockUseUpgradeStatus = vi.fn<() => {showUpgradeBanner: boolean; trialDaysRemaining: number}>(() => ({showUpgradeBanner: false, trialDaysRemaining: 0})); +const mockUseWhatsNewStatus = vi.fn<() => {showWhatsNewBanner: boolean}>(() => ({showWhatsNewBanner: false})); + +vi.mock("@/ember-bridge/ember-bridge", () => ({ + useSidebarVisibility: () => mockUseSidebarVisibility(), +})); + +vi.mock("./use-theme-errors", () => ({ + useActiveThemeErrors: () => mockUseActiveThemeErrors(), +})); + +vi.mock("./use-upgrade-status", () => ({ + useUpgradeStatus: () => mockUseUpgradeStatus(), +})); + +vi.mock("./use-whats-new-status", () => ({ + useWhatsNewStatus: () => mockUseWhatsNewStatus(), +})); + +vi.mock("../theme-errors-banner", () => ({ + default: () => Theme Error Banner, +})); + +vi.mock("../upgrade-banner", () => ({ + default: ({trialDaysRemaining}: {trialDaysRemaining: number}) => Upgrade Banner ({trialDaysRemaining}), +})); + +vi.mock("@/whats-new/components/whats-new-banner", () => ({ + default: () => Whats New Banner, +})); + +describe("useSidebarBannerState", () => { + beforeEach(() => { + mockUseSidebarVisibility.mockReturnValue(true); + mockUseActiveThemeErrors.mockReturnValue({hasErrors: false}); + mockUseUpgradeStatus.mockReturnValue({showUpgradeBanner: false, trialDaysRemaining: 0}); + mockUseWhatsNewStatus.mockReturnValue({showWhatsNewBanner: false}); + }); + + test("returns no banner when editor is open", () => { + mockUseSidebarVisibility.mockReturnValue(false); + mockUseActiveThemeErrors.mockReturnValue({hasErrors: true}); + mockUseUpgradeStatus.mockReturnValue({showUpgradeBanner: true, trialDaysRemaining: 7}); + mockUseWhatsNewStatus.mockReturnValue({showWhatsNewBanner: true}); + + const {result} = renderHook(() => useSidebarBannerState()); + + expect(result.current.hasBanner).toBe(false); + expect(result.current.banner).toBeNull(); + expect(result.current.bannerType).toBeNull(); + }); + + test("prefers theme error banner over other banners", () => { + mockUseActiveThemeErrors.mockReturnValue({hasErrors: true}); + mockUseUpgradeStatus.mockReturnValue({showUpgradeBanner: true, trialDaysRemaining: 7}); + mockUseWhatsNewStatus.mockReturnValue({showWhatsNewBanner: true}); + + const {result} = renderHook(() => useSidebarBannerState()); + + expect(result.current.hasBanner).toBe(true); + expect(result.current.bannerType).toBe('theme-errors'); + }); + + test("returns upgrade banner state when only upgrade is active", () => { + mockUseUpgradeStatus.mockReturnValue({showUpgradeBanner: true, trialDaysRemaining: 7}); + + const {result} = renderHook(() => useSidebarBannerState()); + + expect(result.current.hasBanner).toBe(true); + expect(result.current.bannerType).toBe('upgrade'); + }); + + test("returns whats new banner state when only whats new is active", () => { + mockUseWhatsNewStatus.mockReturnValue({showWhatsNewBanner: true}); + + const {result} = renderHook(() => useSidebarBannerState()); + + expect(result.current.hasBanner).toBe(true); + expect(result.current.bannerType).toBe('whats-new'); + }); +}); diff --git a/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx new file mode 100644 index 00000000000..84a0b703002 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/hooks/use-sidebar-banner-state.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from "react"; + +import { useSidebarVisibility } from "@/ember-bridge/ember-bridge"; +import ThemeErrorsBanner from "@/layout/app-sidebar/theme-errors-banner"; +import UpgradeBanner from "@/layout/app-sidebar/upgrade-banner"; +import WhatsNewBanner from "@/whats-new/components/whats-new-banner"; + +import { useUpgradeStatus } from "./use-upgrade-status"; +import { useWhatsNewStatus } from "./use-whats-new-status"; +import { useActiveThemeErrors } from "./use-theme-errors"; + +export interface SidebarBannerState { + bannerType: 'theme-errors' | 'upgrade' | 'whats-new' | null; + banner: ReactNode; + hasBanner: boolean; +} + +export function useSidebarBannerState(): SidebarBannerState { + const {hasErrors} = useActiveThemeErrors(); + const {showUpgradeBanner, trialDaysRemaining} = useUpgradeStatus(); + const {showWhatsNewBanner} = useWhatsNewStatus(); + const sidebarVisible = useSidebarVisibility(); + + if (!sidebarVisible) { + return { + bannerType: null, + banner: null, + hasBanner: false + }; + } + + if (hasErrors) { + return { + bannerType: 'theme-errors', + banner: , + hasBanner: true + }; + } + + if (showUpgradeBanner) { + return { + bannerType: 'upgrade', + banner: , + hasBanner: true + }; + } + + if (showWhatsNewBanner) { + return { + bannerType: 'whats-new', + banner: , + hasBanner: true + }; + } + + return { + bannerType: null, + banner: null, + hasBanner: false + }; +} diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 77da92d921b..6ea7d786e04 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -25,6 +25,7 @@ function NavContent({ ...props }: React.ComponentProps) { const memberCount = useMemberCount(); const routing = useEmberRouting(); const commentModerationEnabled = useFeatureFlag('commentModeration'); + const membersForwardEnabled = useFeatureFlag('membersForward'); const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -128,7 +129,7 @@ function NavContent({ ...props }: React.ComponentProps) { {showMembers && ( diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index cf787c50f23..95bd4d8bc54 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/comments-ui", - "version": "1.3.6", + "version": "1.3.7", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/comments-ui/src/components/content/content.tsx b/apps/comments-ui/src/components/content/content.tsx index d0f24c618bb..52737774444 100644 --- a/apps/comments-ui/src/components/content/content.tsx +++ b/apps/comments-ui/src/components/content/content.tsx @@ -101,8 +101,17 @@ const Content = () => { }, []); useEffect(() => { + // Capture the parent window reference once so the handler and cleanup + // always use the same object. window.parent becomes null when the + // iframe is detached from the DOM, but the captured reference remains + // valid for removing the listener. + const parentWindow = window.parent; + if (!parentWindow) { + return; + } + const handleHashChange = () => { - const commentId = parseCommentIdFromHash(window.parent.location.hash); + const commentId = parseCommentIdFromHash(parentWindow.location.hash); if (commentId && containerRef.current) { const doc = containerRef.current.ownerDocument; const element = doc.getElementById(commentId); @@ -112,8 +121,8 @@ const Content = () => { } }; - window.parent.addEventListener('hashchange', handleHashChange); - return () => window.parent.removeEventListener('hashchange', handleHashChange); + parentWindow.addEventListener('hashchange', handleHashChange); + return () => parentWindow.removeEventListener('hashchange', handleHashChange); }, [scrollToComment]); useEffect(() => { diff --git a/apps/comments-ui/test/unit/components/content/content.test.jsx b/apps/comments-ui/test/unit/components/content/content.test.jsx index 9346b3cb5ed..1a9923bd10d 100644 --- a/apps/comments-ui/test/unit/components/content/content.test.jsx +++ b/apps/comments-ui/test/unit/components/content/content.test.jsx @@ -1,6 +1,6 @@ import Content from '../../../../src/components/content/content'; import {AppContext} from '../../../../src/app-context'; -import {render, screen} from '@testing-library/react'; +import {render, screen, act} from '@testing-library/react'; const contextualRender = (ui, {appContext, ...renderOptions}) => { const member = appContext?.member ?? null; @@ -64,4 +64,77 @@ describe('', function () { expect(screen.queryByTestId('main-form')).toBeInTheDocument(); }); }); + + describe('hashchange listener', function () { + let originalParent; + + beforeEach(() => { + originalParent = window.parent; + }); + + afterEach(() => { + Object.defineProperty(window, 'parent', { + value: originalParent, + writable: true, + configurable: true + }); + }); + + it('does not throw when window.parent becomes null after mount', function () { + // Track errors thrown during event dispatch (jsdom reports these + // as uncaught errors even though they happen inside an event handler) + const errors = []; + const onError = (e) => { + errors.push(e); + e.preventDefault(); + }; + window.addEventListener('error', onError); + + contextualRender(, {appContext: {}}); + + // Simulate iframe detachment: window.parent becomes null. + // This happens when the iframe is removed from the DOM while + // a hashchange event is still queued on the parent window. + Object.defineProperty(window, 'parent', { + value: null, + writable: true, + configurable: true + }); + + act(() => { + // Dispatch hashchange on the original parent window — this is + // what happens in production: the listener was added to the parent, + // and the event fires after the iframe is detached + originalParent.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + window.removeEventListener('error', onError); + expect(errors).toHaveLength(0); + }); + + it('cleanup does not throw when window.parent becomes null on unmount', function () { + const errors = []; + const onError = (e) => { + errors.push(e); + e.preventDefault(); + }; + window.addEventListener('error', onError); + + const {unmount} = contextualRender(, {appContext: {}}); + + // Simulate iframe detachment before React cleanup runs + Object.defineProperty(window, 'parent', { + value: null, + writable: true, + configurable: true + }); + + // Unmounting triggers the useEffect cleanup which calls + // window.parent.removeEventListener — this should not throw + unmount(); + + window.removeEventListener('error', onError); + expect(errors).toHaveLength(0); + }); + }); }); diff --git a/apps/portal/package.json b/apps/portal/package.json index 56a4de7ed5e..9681f69d6d7 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.5", + "version": "2.64.7", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js index c93dedd10c6..2ce22170abd 100644 --- a/apps/portal/src/components/pages/magic-link-page.js +++ b/apps/portal/src/components/pages/magic-link-page.js @@ -163,9 +163,8 @@ export default class MagicLinkPage extends React.Component { } renderCloseButton() { - const {site, inboxLinks} = this.context; - const isInboxLinksEnabled = site.labs?.inboxlinks !== false; - if (isInboxLinksEnabled && inboxLinks && !isIos(navigator)) { + const {inboxLinks} = this.context; + if (inboxLinks && !isIos(navigator)) { return ; } else { return ( @@ -234,8 +233,7 @@ export default class MagicLinkPage extends React.Component { } renderOTCForm() { - const {action, actionErrorMessage, otcRef, site, inboxLinks} = this.context; - const isInboxLinksEnabled = site.labs?.inboxlinks !== false; + const {action, actionErrorMessage, otcRef, inboxLinks} = this.context; const errors = this.state.errors || {}; if (!otcRef) { @@ -279,7 +277,7 @@ export default class MagicLinkPage extends React.Component {