diff --git a/ghost/portal/src/App.js b/ghost/portal/src/App.js
index ae37462c66..a9b11e0b75 100644
--- a/ghost/portal/src/App.js
+++ b/ghost/portal/src/App.js
@@ -5,8 +5,8 @@ import PopupModal from './components/PopupModal';
 import setupGhostApi from './utils/api';
 import AppContext from './AppContext';
 import {hasMode} from './utils/check-mode';
-import {getActivePage, isAccountPage} from './pages';
 import * as Fixtures from './utils/fixtures';
+import {getActivePage, isAccountPage, isOfferPage} from './pages';
 import ActionHandler from './actions';
 import './App.css';
 import NotificationParser from './utils/notifications';
@@ -728,7 +728,7 @@ export default class App extends React.Component {
     getContextMember({page, member, customSiteUrl}) {
         if (hasMode(['dev', 'preview'], {customSiteUrl})) {
             /** Use dummy member(free or paid) for account pages in dev/preview mode*/
-            if (isAccountPage({page})) {
+            if (isAccountPage({page}) || isOfferPage({page})) {
                 if (hasMode(['dev'], {customSiteUrl})) {
                     return member || Fixtures.member.free;
                 } else if (hasMode(['preview'])) {
diff --git a/ghost/portal/src/pages.js b/ghost/portal/src/pages.js
index ac86421d39..2718bde9f9 100644
--- a/ghost/portal/src/pages.js
+++ b/ghost/portal/src/pages.js
@@ -33,4 +33,8 @@ export const isAccountPage = function ({page}) {
     return page.includes('account');
 };
 
+export const isOfferPage = function ({page}) {
+    return page.includes('offer');
+};
+
 export default Pages;
diff --git a/ghost/portal/src/tests/UpgradeFlow.test.js b/ghost/portal/src/tests/UpgradeFlow.test.js
new file mode 100644
index 0000000000..f758c2fd32
--- /dev/null
+++ b/ghost/portal/src/tests/UpgradeFlow.test.js
@@ -0,0 +1,360 @@
+import React from 'react';
+import App from '../App.js';
+import {fireEvent, appRender, within} from '../utils/test-utils';
+import {offer as FixtureOffer, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
+import setupGhostApi from '../utils/api.js';
+
+const offerSetup = async ({site, member = null, offer}) => {
+    const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
+    ghostApi.init = jest.fn(() => {
+        return Promise.resolve({
+            site,
+            member
+        });
+    });
+
+    ghostApi.member.sendMagicLink = jest.fn(() => {
+        return Promise.resolve('success');
+    });
+
+    ghostApi.site.offer = jest.fn(() => {
+        return Promise.resolve({
+            offers: [offer]
+        });
+    });
+
+    ghostApi.member.checkoutPlan = jest.fn(() => {
+        return Promise.resolve();
+    });
+
+    const utils = appRender(
+        <App api={ghostApi} />
+    );
+
+    const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
+    const popupFrame = utils.queryByTitle(/portal-popup/i);
+    const popupIframeDocument = popupFrame.contentDocument;
+    const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
+    const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
+    const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+    const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
+    const siteTitle = within(popupIframeDocument).queryByText(site.title);
+    const offerName = within(popupIframeDocument).queryByText(offer.name);
+    const offerDescription = within(popupIframeDocument).queryByText(offer.display_description);
+
+    const freePlanTitle = within(popupIframeDocument).queryByText('Free');
+    const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
+    const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
+    const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
+    return {
+        ghostApi,
+        popupIframeDocument,
+        popupFrame,
+        triggerButtonFrame,
+        siteTitle,
+        emailInput,
+        nameInput,
+        signinButton,
+        submitButton,
+        freePlanTitle,
+        monthlyPlanTitle,
+        yearlyPlanTitle,
+        fullAccessTitle,
+        offerName,
+        offerDescription,
+        ...utils
+    };
+};
+
+const setup = async ({site, member = null}) => {
+    const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
+    ghostApi.init = jest.fn(() => {
+        return Promise.resolve({
+            site,
+            member
+        });
+    });
+
+    ghostApi.member.sendMagicLink = jest.fn(() => {
+        return Promise.resolve('success');
+    });
+
+    ghostApi.member.checkoutPlan = jest.fn(() => {
+        return Promise.resolve();
+    });
+
+    const utils = appRender(
+        <App api={ghostApi} />
+    );
+
+    const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
+    const popupFrame = utils.queryByTitle(/portal-popup/i);
+    const popupIframeDocument = popupFrame.contentDocument;
+    const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
+    const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
+    const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+    const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
+    const siteTitle = within(popupIframeDocument).queryByText(site.title);
+    const freePlanTitle = within(popupIframeDocument).queryByText('Free');
+    const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
+    const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
+    const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
+    const accountHomeTitle = within(popupIframeDocument).queryByText('Your account');
+    const viewPlansButton = within(popupIframeDocument).queryByRole('button', {name: 'View plans'});
+    return {
+        ghostApi,
+        popupIframeDocument,
+        popupFrame,
+        triggerButtonFrame,
+        siteTitle,
+        emailInput,
+        nameInput,
+        signinButton,
+        submitButton,
+        freePlanTitle,
+        monthlyPlanTitle,
+        yearlyPlanTitle,
+        fullAccessTitle,
+        accountHomeTitle,
+        viewPlansButton,
+        ...utils
+    };
+};
+
+const multiTierSetup = async ({site, member = null}) => {
+    const ghostApi = setupGhostApi({siteUrl: 'https://example.com'});
+    ghostApi.init = jest.fn(() => {
+        return Promise.resolve({
+            site,
+            member
+        });
+    });
+
+    ghostApi.member.sendMagicLink = jest.fn(() => {
+        return Promise.resolve('success');
+    });
+
+    ghostApi.member.checkoutPlan = jest.fn(() => {
+        return Promise.resolve();
+    });
+
+    const utils = appRender(
+        <App api={ghostApi} />
+    );
+    const freeTierDescription = site.products?.find(p => p.type === 'free')?.description;
+    const triggerButtonFrame = await utils.findByTitle(/portal-trigger/i);
+    const popupFrame = utils.queryByTitle(/portal-popup/i);
+    const popupIframeDocument = popupFrame.contentDocument;
+    const emailInput = within(popupIframeDocument).queryByLabelText(/email/i);
+    const nameInput = within(popupIframeDocument).queryByLabelText(/name/i);
+    const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+    const signinButton = within(popupIframeDocument).queryByRole('button', {name: 'Sign in'});
+    const siteTitle = within(popupIframeDocument).queryByText(site.title);
+    const freePlanTitle = within(popupIframeDocument).queryAllByText(/free$/i);
+    const freePlanDescription = within(popupIframeDocument).queryAllByText(freeTierDescription);
+    const monthlyPlanTitle = within(popupIframeDocument).queryByText('Monthly');
+    const yearlyPlanTitle = within(popupIframeDocument).queryByText('Yearly');
+    const fullAccessTitle = within(popupIframeDocument).queryByText('Full access');
+    const accountHomeTitle = within(popupIframeDocument).queryByText('Your account');
+    const viewPlansButton = within(popupIframeDocument).queryByRole('button', {name: 'View plans'});
+    return {
+        ghostApi,
+        popupIframeDocument,
+        popupFrame,
+        triggerButtonFrame,
+        siteTitle,
+        emailInput,
+        nameInput,
+        signinButton,
+        submitButton,
+        freePlanTitle,
+        monthlyPlanTitle,
+        yearlyPlanTitle,
+        fullAccessTitle,
+        freePlanDescription,
+        accountHomeTitle,
+        viewPlansButton,
+        ...utils
+    };
+};
+
+describe('Logged-in free member', () => {
+    describe('can upgrade on single tier site', () => {
+        test('with default settings on monthly plan', async () => {
+            const {
+                ghostApi, popupFrame, triggerButtonFrame,
+                popupIframeDocument, accountHomeTitle, viewPlansButton
+            } = await setup({
+                site: FixtureSite.singleTier.basic,
+                member: FixtureMember.free
+            });
+
+            expect(popupFrame).toBeInTheDocument();
+            expect(triggerButtonFrame).toBeInTheDocument();
+            expect(accountHomeTitle).toBeInTheDocument();
+            expect(viewPlansButton).toBeInTheDocument();
+
+            const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
+
+            fireEvent.click(viewPlansButton);
+            await within(popupIframeDocument).findByText('Monthly');
+            const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+
+            fireEvent.click(submitButton);
+            expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
+                metadata: {
+                    checkoutType: 'upgrade'
+                },
+                offerId: undefined,
+                plan: singleTierProduct.monthlyPrice.id
+            });
+        });
+
+        test('with default settings on yearly plan', async () => {
+            const {
+                ghostApi, popupFrame, triggerButtonFrame,
+                popupIframeDocument, accountHomeTitle, viewPlansButton
+            } = await setup({
+                site: FixtureSite.singleTier.basic,
+                member: FixtureMember.free
+            });
+
+            expect(popupFrame).toBeInTheDocument();
+            expect(triggerButtonFrame).toBeInTheDocument();
+            expect(accountHomeTitle).toBeInTheDocument();
+            expect(viewPlansButton).toBeInTheDocument();
+
+            const singleTierProduct = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid');
+
+            fireEvent.click(viewPlansButton);
+            await within(popupIframeDocument).findByText('Monthly');
+            const yearlyPlanContainer = await within(popupIframeDocument).findByText('Yearly');
+            fireEvent.click(yearlyPlanContainer.parentNode);
+            // added fake timeout for react state delay in setting plan
+            await new Promise(r => setTimeout(r, 10));
+
+            const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+
+            fireEvent.click(submitButton);
+            expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
+                metadata: {
+                    checkoutType: 'upgrade'
+                },
+                offerId: undefined,
+                plan: singleTierProduct.yearlyPrice.id
+            });
+        });
+
+        test('to an offer via link', async () => {
+            window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
+            const {
+                ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
+                siteTitle,
+                offerName, offerDescription
+            } = await offerSetup({
+                site: FixtureSite.singleTier.basic,
+                member: FixtureMember.altFree,
+                offer: FixtureOffer
+            });
+            let planId = FixtureSite.singleTier.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
+            let offerId = FixtureOffer.id;
+            expect(popupFrame).toBeInTheDocument();
+            expect(triggerButtonFrame).toBeInTheDocument();
+            expect(siteTitle).toBeInTheDocument();
+            expect(emailInput).toBeInTheDocument();
+            expect(nameInput).toBeInTheDocument();
+            expect(signinButton).not.toBeInTheDocument();
+            expect(submitButton).toBeInTheDocument();
+            expect(offerName).toBeInTheDocument();
+            expect(offerDescription).toBeInTheDocument();
+
+            expect(emailInput).toHaveValue('jimmie@example.com');
+            expect(nameInput).toHaveValue('Jimmie Larson');
+            fireEvent.click(submitButton);
+
+            expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
+                email: 'jimmie@example.com',
+                name: 'Jimmie Larson',
+                offerId,
+                plan: planId
+            });
+
+            window.location.hash = '';
+        });
+    });
+});
+
+describe('Logged-in free member', () => {
+    describe('can upgrade on multi tier site', () => {
+        test('with default settings', async () => {
+            const {
+                ghostApi, popupFrame, triggerButtonFrame,
+                popupIframeDocument, accountHomeTitle, viewPlansButton
+            } = await multiTierSetup({
+                site: FixtureSite.multipleTiers.basic,
+                member: FixtureMember.free
+            });
+
+            expect(popupFrame).toBeInTheDocument();
+            expect(triggerButtonFrame).toBeInTheDocument();
+            expect(accountHomeTitle).toBeInTheDocument();
+            expect(viewPlansButton).toBeInTheDocument();
+
+            const singleTierProduct = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid');
+
+            fireEvent.click(viewPlansButton);
+            await within(popupIframeDocument).findByText('Monthly');
+
+            // allow default checkbox switch to yearly
+            await new Promise(r => setTimeout(r, 10));
+
+            const submitButton = within(popupIframeDocument).queryByRole('button', {name: 'Continue'});
+
+            fireEvent.click(submitButton);
+            expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
+                metadata: {
+                    checkoutType: 'upgrade'
+                },
+                offerId: undefined,
+                plan: singleTierProduct.yearlyPrice.id
+            });
+        });
+
+        test('to an offer via link', async () => {
+            window.location.hash = '#/portal/offers/61fa22bd0cbecc7d423d20b3';
+            const {
+                ghostApi, popupFrame, triggerButtonFrame, emailInput, nameInput, signinButton, submitButton,
+                siteTitle,
+                offerName, offerDescription
+            } = await offerSetup({
+                site: FixtureSite.multipleTiers.basic,
+                member: FixtureMember.altFree,
+                offer: FixtureOffer
+            });
+            let planId = FixtureSite.multipleTiers.basic.products.find(p => p.type === 'paid').monthlyPrice.id;
+            let offerId = FixtureOffer.id;
+            expect(popupFrame).toBeInTheDocument();
+            expect(triggerButtonFrame).toBeInTheDocument();
+            expect(siteTitle).toBeInTheDocument();
+            expect(emailInput).toBeInTheDocument();
+            expect(nameInput).toBeInTheDocument();
+            expect(signinButton).not.toBeInTheDocument();
+            expect(submitButton).toBeInTheDocument();
+            expect(offerName).toBeInTheDocument();
+            expect(offerDescription).toBeInTheDocument();
+
+            expect(emailInput).toHaveValue('jimmie@example.com');
+            expect(nameInput).toHaveValue('Jimmie Larson');
+            fireEvent.click(submitButton);
+
+            expect(ghostApi.member.checkoutPlan).toHaveBeenLastCalledWith({
+                email: 'jimmie@example.com',
+                name: 'Jimmie Larson',
+                offerId,
+                plan: planId
+            });
+
+            window.location.hash = '';
+        });
+    });
+});
diff --git a/ghost/portal/src/utils/test-fixtures.js b/ghost/portal/src/utils/test-fixtures.js
index d383715a77..e122dc5517 100644
--- a/ghost/portal/src/utils/test-fixtures.js
+++ b/ghost/portal/src/utils/test-fixtures.js
@@ -153,6 +153,15 @@ export const member = {
         avatarImage: '',
         subscribed: true
     }),
+    altFree: getMemberData({
+        name: 'Jimmie Larson',
+        email: 'jimmie@example.com',
+        firstname: 'Jimmie',
+        subscriptions: [],
+        paid: false,
+        avatarImage: '',
+        subscribed: true
+    }),
     paid: getMemberData({
         paid: true,
         subscriptions: [