diff --git a/apps/admin-x-design-system/src/global/Button.tsx b/apps/admin-x-design-system/src/global/Button.tsx index 5c91bfeb26..6a413e71b8 100644 --- a/apps/admin-x-design-system/src/global/Button.tsx +++ b/apps/admin-x-design-system/src/global/Button.tsx @@ -27,11 +27,9 @@ export interface ButtonProps extends Omit, 'label' loadingIndicatorColor?: LoadingIndicatorColor; outlineOnMobile?: boolean; onClick?: (e?:React.MouseEvent) => void; - testId?: string; } const Button: React.FC = ({ - testId, size = 'md', label = '', hideLabel = false, @@ -144,7 +142,6 @@ const Button: React.FC = ({ ; const buttonElement = React.createElement(tag, {className: className, - 'data-testid': testId, disabled: disabled, type: 'button', onClick: onClick, diff --git a/apps/admin-x-design-system/src/global/modal/Modal.tsx b/apps/admin-x-design-system/src/global/modal/Modal.tsx index af352f423f..9622f7eeb7 100644 --- a/apps/admin-x-design-system/src/global/modal/Modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/Modal.tsx @@ -416,7 +416,7 @@ const Modal: React.FC = ({ (
{title && {title}}
-
) : diff --git a/apps/admin-x-framework/src/test/acceptance.ts b/apps/admin-x-framework/src/test/acceptance.ts index e5c255b891..ea7f3cc2d4 100644 --- a/apps/admin-x-framework/src/test/acceptance.ts +++ b/apps/admin-x-framework/src/test/acceptance.ts @@ -72,7 +72,8 @@ const defaultLabFlags = { outboundLinkTagging: false, announcementBar: false, signupForm: false, - members: false + members: false, + adminXOffers: false }; // Inject defaultLabFlags into responseFixtures.settings and config diff --git a/apps/admin-x-settings/src/components/Sidebar.tsx b/apps/admin-x-settings/src/components/Sidebar.tsx index 45c5190902..cec7372770 100644 --- a/apps/admin-x-settings/src/components/Sidebar.tsx +++ b/apps/admin-x-settings/src/components/Sidebar.tsx @@ -35,7 +35,7 @@ const Sidebar: React.FC = () => { const {updateRoute} = useRouting(); const searchInputRef = useRef(null); const {isAnyTextFieldFocused} = useFocusContext(); - // const hasOffersLabs = useFeatureFlag('adminXOffers'); + const hasOffersLabs = useFeatureFlag('adminXOffers'); // Focus in on search field when pressing "/" useEffect(() => { @@ -142,7 +142,7 @@ const Sidebar: React.FC = () => { {hasRecommendations && } - {hasStripeEnabled && } + {hasOffersLabs && hasStripeEnabled && } {hasTipsAndDonations && } diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx index bc6d49a6b9..793ae8be73 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx @@ -47,6 +47,10 @@ const features = [{ title: 'Tips & donations', description: 'Enables publishers to collect one-time payments', flag: 'tipsAndDonations' +},{ + title: 'AdminX Offers', + description: 'Enables the new offers UI in AdminX settings', + flag: 'adminXOffers' },{ title: 'Filter by email disabled', description: 'Allows filtering members by email disabled', diff --git a/apps/admin-x-settings/src/components/settings/growth/GrowthSettings.tsx b/apps/admin-x-settings/src/components/settings/growth/GrowthSettings.tsx index 5d0233a75a..7b704ff768 100644 --- a/apps/admin-x-settings/src/components/settings/growth/GrowthSettings.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/GrowthSettings.tsx @@ -18,7 +18,7 @@ export const searchKeywords = { const GrowthSettings: React.FC = () => { const hasTipsAndDonations = useFeatureFlag('tipsAndDonations'); const hasRecommendations = useFeatureFlag('recommendations'); - // const hasOffersLabs = useFeatureFlag('adminXOffers'); + const hasOffersLabs = useFeatureFlag('adminXOffers'); const {config, settings} = useGlobalData(); const hasStripeEnabled = checkStripeEnabled(settings || [], config || {}); @@ -26,7 +26,7 @@ const GrowthSettings: React.FC = () => { {hasRecommendations && } - {hasStripeEnabled && } + {hasOffersLabs && hasStripeEnabled && } {hasTipsAndDonations && } ); diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx index ce50b43f07..fb98b8123e 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/AddOfferModal.tsx @@ -1,4 +1,5 @@ import PortalFrame from '../../membership/portal/PortalFrame'; +import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import {Button} from '@tryghost/admin-x-design-system'; import {ErrorMessages, useForm} from '@tryghost/admin-x-framework/hooks'; import {Form, Icon, PreviewModalContent, Select, SelectOption, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; @@ -10,6 +11,7 @@ import {useAddOffer} from '@tryghost/admin-x-framework/api/offers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; import {useEffect, useMemo, useState} from 'react'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; +import {useModal} from '@ebay/nice-modal-react'; import {useRouting} from '@tryghost/admin-x-framework/routing'; // we should replace this with a library @@ -209,7 +211,6 @@ const Sidebar: React.FC = ({tierOptions, option.value === overrides.duration)} - testId='duration-select-offers' title='Duration' onSelect={e => handleDurationChange(e?.value || '')} /> @@ -317,7 +316,9 @@ const AddOfferModal = () => { ]; const [href, setHref] = useState(''); + const modal = useModal(); const {updateRoute} = useRouting(); + const hasOffers = useFeatureFlag('adminXOffers'); const {data: {tiers} = {}} = useBrowseTiers(); const activeTiers = getPaidActiveTiers(tiers || []); const tierCadenceOptions = getTiersCadences(activeTiers); @@ -555,6 +556,13 @@ const AddOfferModal = () => { })); }; + useEffect(() => { + if (!hasOffers) { + modal.remove(); + updateRoute(''); + } + }, [hasOffers, modal, updateRoute]); + const cancelAddOffer = () => { if (allOffers.length > 0) { updateRoute('offers/edit'); diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx index 376447a5cb..c02fee6dd7 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/EditOfferModal.tsx @@ -1,6 +1,6 @@ -import NiceModal from '@ebay/nice-modal-react'; +import NiceModal, {useModal} from '@ebay/nice-modal-react'; import PortalFrame from '../../membership/portal/PortalFrame'; -// import useFeatureFlag from '../../../../hooks/useFeatureFlag'; +import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import {Button, ConfirmationModal, Form, PreviewModalContent, TextArea, TextField, showToast} from '@tryghost/admin-x-design-system'; import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks'; import {Offer, useBrowseOffersById, useEditOffer} from '@tryghost/admin-x-framework/api/offers'; @@ -177,12 +177,21 @@ const Sidebar: React.FC<{ const EditOfferModal: React.FC<{id: string}> = ({id}) => { const {siteData} = useGlobalData(); + const modal = useModal(); const {updateRoute} = useRouting(); const handleError = useHandleError(); + const hasOffers = useFeatureFlag('adminXOffers'); const {mutateAsync: editOffer} = useEditOffer(); const [href, setHref] = useState(''); + useEffect(() => { + if (!hasOffers) { + modal.remove(); + updateRoute(''); + } + }, [hasOffers, modal, updateRoute]); + const {data: {offers: offerById = []} = {}} = useBrowseOffersById(id ? id : ''); const {formState, saveState, updateForm, setFormState, handleSave, validate, errors, clearError, okProps} = useForm({ diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx index 507a96dd24..7d16292ec7 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/OfferSuccess.tsx @@ -103,7 +103,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => {

You can share the link anywhere. In your newsletter, social media, a podcast, or in-person. It all just works.

- +
OR
diff --git a/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx b/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx index 29ec0555f7..b581bef775 100644 --- a/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx +++ b/apps/admin-x-settings/src/components/settings/growth/offers/OffersIndex.tsx @@ -1,3 +1,4 @@ +import useFeatureFlag from '../../../../hooks/useFeatureFlag'; import {Button, Tab, TabView} from '@tryghost/admin-x-design-system'; import {ButtonGroup, ButtonProps} from '@tryghost/admin-x-design-system'; import {Modal} from '@tryghost/admin-x-design-system'; @@ -9,11 +10,11 @@ import {currencyToDecimal, getSymbol} from '../../../../utils/currency'; import {getHomepageUrl} from '@tryghost/admin-x-framework/api/site'; import {numberWithCommas} from '../../../../utils/helpers'; import {useBrowseOffers} from '@tryghost/admin-x-framework/api/offers'; +import {useEffect, useState} from 'react'; import {useGlobalData} from '../../../providers/GlobalDataProvider'; import {useModal} from '@ebay/nice-modal-react'; import {useRouting} from '@tryghost/admin-x-framework/routing'; import {useSortingState} from '../../../providers/SettingsAppProvider'; -import {useState} from 'react'; export type OfferType = 'percent' | 'fixed' | 'trial'; @@ -60,6 +61,7 @@ export const getOfferDiscount = (type: string, amount: number, cadence: string, break; }; + // Check if updatedPrice is negative, if so, set it to 0 if (updatedPrice < 0) { updatedPrice = 0; } @@ -92,6 +94,7 @@ export const CopyLinkButton: React.FC<{offerCode: string}> = ({offerCode}) => { export const OffersIndexModal = () => { const modal = useModal(); const {updateRoute} = useRouting(); + const hasOffers = useFeatureFlag('adminXOffers'); const {data: {offers: allOffers = []} = {}} = useBrowseOffers({ searchParams: { limit: 'all' @@ -120,6 +123,13 @@ export const OffersIndexModal = () => { const sortOption = offersSorting?.option || 'date-added'; const sortDirection = offersSorting?.direction || 'desc'; + useEffect(() => { + if (!hasOffers) { + modal.remove(); + updateRoute(''); + } + }, [hasOffers, modal, updateRoute]); + const handleOfferEdit = (id:string) => { // TODO: implement sessionStorage.setItem('editOfferPageSource', 'offersIndex'); @@ -135,6 +145,7 @@ export const OffersIndexModal = () => { case 'redemptions': return multiplier * (offer1.redemption_count - offer2.redemption_count); default: + // 'date-added' or unknown option, use default sorting return multiplier * ((offer1.created_at ? new Date(offer1.created_at).getTime() : 0) - (offer2.created_at ? new Date(offer2.created_at).getTime() : 0)); } }); @@ -153,6 +164,7 @@ export const OffersIndexModal = () => { } {sortedOffers.filter((offer) => { const offerTier = allTiers?.find(tier => tier.id === offer?.tier.id); + //Check to filter out offers with archived offerTier return (selectedTab === 'active' && (offer.status === 'active' && offerTier && offerTier.active === true)) || (selectedTab === 'archived' && (offer.status === 'archived' || (offerTier && offerTier.active === false))); }).map((offer) => { @@ -167,7 +179,7 @@ export const OffersIndexModal = () => { const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', offerTier); return ( - + handleOfferEdit(offer?.id ? offer.id : '') : () => {}}>{offer?.name}
{offerTier.name} {getOfferCadence(offer.cadence)}
handleOfferEdit(offer?.id ? offer.id : '') : () => {}}>{discountOffer}
{offer.type !== 'trial' ? getOfferDuration(offer.duration) : 'Trial period'}
handleOfferEdit(offer?.id ? offer.id : '') : () => {}}>{updatedPriceWithCurrency} {offer.type !== 'trial' ? {originalPriceWithCurrency} : null} 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 b46007cb20..4748494c25 100644 --- a/apps/admin-x-settings/test/acceptance/membership/offers.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/offers.test.ts @@ -1,11 +1,11 @@ import {expect, test} from '@playwright/test'; import {globalDataRequests} from '../../utils/acceptance'; -import {mockApi, responseFixtures, settingsWithStripe} from '@tryghost/admin-x-framework/test/acceptance'; +import {mockApi, responseFixtures, settingsWithStripe, toggleLabsFlag} from '@tryghost/admin-x-framework/test/acceptance'; test.describe('Offers Modal', () => { - // test.beforeEach(async () => { - // toggleLabsFlag('adminXOffers', true); - // }); + test.beforeEach(async () => { + toggleLabsFlag('adminXOffers', true); + }); test('Offers Modal is available', async ({page}) => { await mockApi({page, requests: { diff --git a/apps/admin-x-settings/test/acceptance/membership/tiers.test.ts b/apps/admin-x-settings/test/acceptance/membership/tiers.test.ts index 51207bf8a0..73d73cfd03 100644 --- a/apps/admin-x-settings/test/acceptance/membership/tiers.test.ts +++ b/apps/admin-x-settings/test/acceptance/membership/tiers.test.ts @@ -54,6 +54,8 @@ test.describe('Tier settings', async () => { await modal.getByRole('button', {name: 'Save & close'}).click(); + await page.pause(); + // await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/); // await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/\$8\/month/); diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index 2f2bd79e29..15d02de20a 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -90,6 +90,9 @@ test.describe('Actions', async () => { await replyButton.click(); const editor = frame.getByTestId('form-editor'); await expect(editor).toBeVisible(); + + await page.pause(); + // Wait for focused await waitEditorFocused(editor); diff --git a/ghost/admin/.lint-todo b/ghost/admin/.lint-todo index 976f26755e..579705fe2d 100644 --- a/ghost/admin/.lint-todo +++ b/ghost/admin/.lint-todo @@ -351,4 +351,3 @@ add|ember-template-lint|no-passed-in-event-handlers|50|48|50|48|138f75fcc7408e61 add|ember-template-lint|no-passed-in-event-handlers|76|48|76|48|8fb95409c6333d15490f0777239f6e04641215e6|1697068800000|1707440400000|1712620800000|app/templates/settings/integrations/slack.hbs add|ember-template-lint|require-valid-alt-text|24|20|24|20|a500be6114ce681e610b6fec7d2a6cea2f4555f7|1697068800000|1707440400000|1712620800000|app/templates/settings/integrations/slack.hbs add|ember-template-lint|no-action|51|56|51|56|2b83cff3852a6f3cb1f2d2613e1bb611e1af4996|1697068800000|1707440400000|1712620800000|app/templates/settings/integrations/unsplash.hbs -remove|ember-template-lint|no-unknown-arguments-for-builtin-components|118|95|118|95|b8aae2daed1c14cf280800b3d282d11fb14851a4|1697068800000|1707440400000|1712620800000|app/components/gh-nav-menu/main.hbs diff --git a/ghost/admin/app/components/gh-nav-menu/main.hbs b/ghost/admin/app/components/gh-nav-menu/main.hbs index 0e1e22c0bc..036d2ce411 100644 --- a/ghost/admin/app/components/gh-nav-menu/main.hbs +++ b/ghost/admin/app/components/gh-nav-menu/main.hbs @@ -117,6 +117,11 @@ {{/if}} + {{#if this.settings.paidMembersEnabled}} +
  • + {{svg-jar "percentage"}}Offers +
  • + {{/if}} {{/if}} {{#if (feature "adminXDemo")}}
  • diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index e0f5c4e960..e63f0eb076 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -82,10 +82,10 @@ Router.map(function () { this.route('member', {path: '/members/:member_id'}); this.route('members-activity'); - // this.route('offers'); + this.route('offers'); - // this.route('offer.new', {path: '/offers/new'}); - // this.route('offer', {path: '/offers/:offer_id'}); + this.route('offer.new', {path: '/offers/new'}); + this.route('offer', {path: '/offers/:offer_id'}); this.route('error404', {path: '/*path'}); diff --git a/ghost/admin/app/routes/offer.js b/ghost/admin/app/routes/offer.js index 29516d7299..d90111cffa 100644 --- a/ghost/admin/app/routes/offer.js +++ b/ghost/admin/app/routes/offer.js @@ -1,80 +1,80 @@ -// import AdminRoute from 'ghost-admin/routes/admin'; -// import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; -// import {action} from '@ember/object'; -// import {inject as service} from '@ember/service'; +import AdminRoute from 'ghost-admin/routes/admin'; +import ConfirmUnsavedChangesModal from '../components/modals/confirm-unsaved-changes'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; -// export default class OffersRoute extends AdminRoute { -// @service modals; -// @service router; +export default class OffersRoute extends AdminRoute { + @service modals; + @service router; -// _requiresBackgroundRefresh = true; + _requiresBackgroundRefresh = true; -// model(params) { -// this._requiresBackgroundRefresh = false; + model(params) { + this._requiresBackgroundRefresh = false; -// if (params.offer_id) { -// return this.store.queryRecord('offer', {id: params.offer_id}); -// } else { -// return this.store.createRecord('offer'); -// } -// } + if (params.offer_id) { + return this.store.queryRecord('offer', {id: params.offer_id}); + } else { + return this.store.createRecord('offer'); + } + } -// setupController(controller, offer) { -// super.setupController(...arguments); + setupController(controller, offer) { + super.setupController(...arguments); -// if (this._requiresBackgroundRefresh) { -// controller.fetchOfferTask.perform(offer.id); -// } -// } + if (this._requiresBackgroundRefresh) { + controller.fetchOfferTask.perform(offer.id); + } + } -// deactivate() { -// // clean up newly created records and revert unsaved changes to existing -// this.controller.offer.rollbackAttributes(); -// this._requiresBackgroundRefresh = true; -// } + deactivate() { + // clean up newly created records and revert unsaved changes to existing + this.controller.offer.rollbackAttributes(); + this._requiresBackgroundRefresh = true; + } -// @action -// async willTransition(transition) { -// if (this.hasConfirmed) { -// return true; -// } + @action + async willTransition(transition) { + if (this.hasConfirmed) { + return true; + } -// transition.abort(); + transition.abort(); -// // wait for any existing confirm modal to be closed before allowing transition -// if (this.confirmModal) { -// return; -// } + // wait for any existing confirm modal to be closed before allowing transition + if (this.confirmModal) { + return; + } -// const shouldLeave = await this.confirmUnsavedChanges(); + const shouldLeave = await this.confirmUnsavedChanges(); -// if (shouldLeave) { -// this.controller.model.rollbackAttributes(); -// this.hasConfirmed = true; -// return transition.retry(); -// } -// } + if (shouldLeave) { + this.controller.model.rollbackAttributes(); + this.hasConfirmed = true; + return transition.retry(); + } + } -// async confirmUnsavedChanges() { -// if (this.controller.model?.hasDirtyAttributes) { -// this.confirmModal = this.modals -// .open(ConfirmUnsavedChangesModal) -// .finally(() => { -// this.confirmModal = null; -// }); + async confirmUnsavedChanges() { + if (this.controller.model?.hasDirtyAttributes) { + this.confirmModal = this.modals + .open(ConfirmUnsavedChangesModal) + .finally(() => { + this.confirmModal = null; + }); -// return this.confirmModal; -// } + return this.confirmModal; + } -// return true; -// } + return true; + } -// @action -// save() { -// this.controller.save(); -// } + @action + save() { + this.controller.save(); + } -// titleToken() { -// return this.controller.offer.name; -// } -// } + titleToken() { + return this.controller.offer.name; + } +} diff --git a/ghost/admin/app/routes/offer/new.js b/ghost/admin/app/routes/offer/new.js index 43bf12f103..50a7bb739a 100644 --- a/ghost/admin/app/routes/offer/new.js +++ b/ghost/admin/app/routes/offer/new.js @@ -1,18 +1,18 @@ -// import OfferRoute from '../offer'; -// import {inject as service} from '@ember/service'; +import OfferRoute from '../offer'; +import {inject as service} from '@ember/service'; -// export default class NewOfferRoute extends OfferRoute { -// @service membersUtils; +export default class NewOfferRoute extends OfferRoute { + @service membersUtils; -// controllerName = 'offer'; -// templateName = 'offer'; + controllerName = 'offer'; + templateName = 'offer'; -// /** -// * First check if we have active tiers -// */ -// beforeModel() { -// if (!this.membersUtils.hasActiveTiers) { -// return this.replaceWith('offers'); -// } -// } -// } + /** + * First check if we have active tiers + */ + beforeModel() { + if (!this.membersUtils.hasActiveTiers) { + return this.replaceWith('offers'); + } + } +} diff --git a/ghost/admin/app/routes/offers.js b/ghost/admin/app/routes/offers.js index bbb67cbbcb..152ba3386e 100644 --- a/ghost/admin/app/routes/offers.js +++ b/ghost/admin/app/routes/offers.js @@ -1,31 +1,31 @@ -// import AdminRoute from 'ghost-admin/routes/admin'; -// import {inject as service} from '@ember/service'; +import AdminRoute from 'ghost-admin/routes/admin'; +import {inject as service} from '@ember/service'; -// export default class offersRoute extends AdminRoute { -// @service store; -// @service feature; +export default class offersRoute extends AdminRoute { + @service store; + @service feature; -// queryParams = { -// type: {refreshModel: true} -// }; + queryParams = { + type: {refreshModel: true} + }; -// beforeModel() { -// super.beforeModel(...arguments); -// // TODO: redirect if members is disabled? -// } + beforeModel() { + super.beforeModel(...arguments); + // TODO: redirect if members is disabled? + } -// model(params) { -// return this.controllerFor('offers').fetchOffersTask.perform(params); -// } + model(params) { + return this.controllerFor('offers').fetchOffersTask.perform(params); + } -// // trigger a background load of members plus labels for filter dropdown -// setupController() { -// super.setupController(...arguments); -// } + // trigger a background load of members plus labels for filter dropdown + setupController() { + super.setupController(...arguments); + } -// buildRouteInfoMetadata() { -// return { -// titleToken: 'Offers' -// }; -// } -// } + buildRouteInfoMetadata() { + return { + titleToken: 'Offers' + }; + } +} diff --git a/ghost/admin/tests/acceptance/offers-test.js b/ghost/admin/tests/acceptance/offers-test.js index f3e7058b49..54a45769a5 100644 --- a/ghost/admin/tests/acceptance/offers-test.js +++ b/ghost/admin/tests/acceptance/offers-test.js @@ -1,111 +1,111 @@ -// import moment from 'moment-timezone'; -// import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; -// import {beforeEach, describe, it} from 'mocha'; -// import {blur, click, currentURL, fillIn, find, findAll, settled} from '@ember/test-helpers'; -// import {enablePaidMembers} from '../helpers/members'; -// import {expect} from 'chai'; -// import {setupApplicationTest} from 'ember-mocha'; -// import {setupMirage} from 'ember-cli-mirage/test-support'; -// import {timeout} from 'ember-concurrency'; -// import {visit} from '../helpers/visit'; +import moment from 'moment-timezone'; +import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; +import {beforeEach, describe, it} from 'mocha'; +import {blur, click, currentURL, fillIn, find, findAll, settled} from '@ember/test-helpers'; +import {enablePaidMembers} from '../helpers/members'; +import {expect} from 'chai'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; +import {timeout} from 'ember-concurrency'; +import {visit} from '../helpers/visit'; -// describe('Acceptance: Offers', function () { -// let hooks = setupApplicationTest(); -// setupMirage(hooks); +describe('Acceptance: Offers', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); -// it('redirects to signin when not authenticated', async function () { -// await invalidateSession(); -// await visit('/offers'); + it('redirects to signin when not authenticated', async function () { + await invalidateSession(); + await visit('/offers'); -// expect(currentURL()).to.equal('/signin'); -// }); + expect(currentURL()).to.equal('/signin'); + }); -// it('redirects non-admins to site', async function () { -// let role = this.server.create('role', {name: 'Editor'}); -// this.server.create('user', {roles: [role]}); + it('redirects non-admins to site', async function () { + let role = this.server.create('role', {name: 'Editor'}); + this.server.create('user', {roles: [role]}); -// await authenticateSession(); -// await visit('/offers'); + await authenticateSession(); + await visit('/offers'); -// expect(currentURL()).to.equal('/site'); -// expect(find('[data-test-nav="offers"]'), 'sidebar link') -// .to.not.exist; -// }); + expect(currentURL()).to.equal('/site'); + expect(find('[data-test-nav="offers"]'), 'sidebar link') + .to.not.exist; + }); -// describe('as owner', function () { -// beforeEach(async function () { -// this.server.loadFixtures('tiers'); + describe('as owner', function () { + beforeEach(async function () { + this.server.loadFixtures('tiers'); -// let role = this.server.create('role', {name: 'Owner'}); -// this.server.create('user', {roles: [role]}); + let role = this.server.create('role', {name: 'Owner'}); + this.server.create('user', {roles: [role]}); -// enablePaidMembers(this.server); + enablePaidMembers(this.server); -// return await authenticateSession(); -// }); + return await authenticateSession(); + }); -// it('it renders, can be navigated, can edit offer', async function () { -// const tier = this.server.create('tier'); -// let offer1 = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); -// this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(2, 'day').valueOf()}); + it('it renders, can be navigated, can edit offer', async function () { + const tier = this.server.create('tier'); + let offer1 = this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(1, 'day').valueOf()}); + this.server.create('offer', {tier: {id: tier.id}, createdAt: moment.utc().subtract(2, 'day').valueOf()}); -// await visit('/offers'); + await visit('/offers'); -// await settled(); + await settled(); -// // lands on correct page -// expect(currentURL(), 'currentURL').to.equal('/offers'); + // lands on correct page + expect(currentURL(), 'currentURL').to.equal('/offers'); -// // it highlights active state in nav menu -// expect( -// find('[data-test-nav="offers"]'), -// 'highlights nav menu item' -// ).to.have.class('active'); + // it highlights active state in nav menu + expect( + find('[data-test-nav="offers"]'), + 'highlights nav menu item' + ).to.have.class('active'); -// // it lists all offers -// expect(findAll('[data-test-list="offers-list-item"]').length, 'offers list count') -// .to.equal(2); + // it lists all offers + expect(findAll('[data-test-list="offers-list-item"]').length, 'offers list count') + .to.equal(2); -// let offer = find('[data-test-list="offers-list-item"]'); -// expect(offer.querySelector('[data-test-list="offer-name"] h3').textContent, 'offer list item name') -// .to.equal(offer1.name); + let offer = find('[data-test-list="offers-list-item"]'); + expect(offer.querySelector('[data-test-list="offer-name"] h3').textContent, 'offer list item name') + .to.equal(offer1.name); -// await visit(`/offers/${offer1.id}`); + await visit(`/offers/${offer1.id}`); -// // second wait is needed for the offer details to settle -// await settled(); + // second wait is needed for the offer details to settle + await settled(); -// // it shows selected offer form -// expect(find('[data-test-input="offer-name"]').value, 'loads correct offer into form') -// .to.equal(offer1.name); + // it shows selected offer form + expect(find('[data-test-input="offer-name"]').value, 'loads correct offer into form') + .to.equal(offer1.name); -// // it maintains active state in nav menu -// expect( -// find('[data-test-nav="offers"]'), -// 'highlights nav menu item' -// ).to.have.class('active'); + // it maintains active state in nav menu + expect( + find('[data-test-nav="offers"]'), + 'highlights nav menu item' + ).to.have.class('active'); -// // trigger save -// await fillIn('[data-test-input="offer-name"]', 'New Name'); -// await blur('[data-test-input="offer-name"]'); + // trigger save + await fillIn('[data-test-input="offer-name"]', 'New Name'); + await blur('[data-test-input="offer-name"]'); -// await click('[data-test-button="save"]'); + await click('[data-test-button="save"]'); -// // extra timeout needed for Travis - sometimes it doesn't update -// // quick enough and an extra wait() call doesn't help -// await timeout(100); + // extra timeout needed for Travis - sometimes it doesn't update + // quick enough and an extra wait() call doesn't help + await timeout(100); -// await click('[data-test-link="offers-back"]'); + await click('[data-test-link="offers-back"]'); -// // lands on correct page -// expect(currentURL(), 'currentURL').to.equal('/offers'); -// }); + // lands on correct page + expect(currentURL(), 'currentURL').to.equal('/offers'); + }); -// it('maintains active state in nav menu when creating a new tag', async function () { -// await visit('offers/new'); -// expect(currentURL()).to.equal('offers/new'); -// expect(find('[data-test-nav="offers"]'), 'highlights nav menu item') -// .to.have.class('active'); -// }); -// }); -// }); + it('maintains active state in nav menu when creating a new tag', async function () { + await visit('offers/new'); + expect(currentURL()).to.equal('offers/new'); + expect(find('[data-test-nav="offers"]'), 'highlights nav menu item') + .to.have.class('active'); + }); + }); +}); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 4e3c4e601c..745bf50dd8 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -44,7 +44,7 @@ const ALPHA_FEATURES = [ 'tipsAndDonations', 'importMemberTier', 'lexicalIndicators', - // 'adminXOffers', + 'adminXOffers', 'filterEmailDisabled', 'adminXDemo', 'newEmailAddresses', diff --git a/ghost/core/test/e2e-browser/admin/tiers.spec.js b/ghost/core/test/e2e-browser/admin/tiers.spec.js index 28af174f5c..7bce02a0d6 100644 --- a/ghost/core/test/e2e-browser/admin/tiers.spec.js +++ b/ghost/core/test/e2e-browser/admin/tiers.spec.js @@ -23,16 +23,19 @@ test.describe('Admin', () => { monthlyPrice: 5, yearlyPrice: 50 }); - const {offerName} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'Get 5% Off!', tierName, - offerType: 'freeTrial', - amount: 14 + offerType: 'discount', + amount: 5 }); - await sharedPage.goto('/ghost/'); - await sharedPage.goto('/ghost/#/settings/offers'); - await expect(sharedPage.getByTestId('offers')).toContainText(offerName); + await test.step('Check that offers and tiers are available on Offers page', async () => { + await sharedPage.locator('[data-test-nav="offers"]').click(); + await sharedPage.waitForSelector('[data-test-offers-list]'); + await expect(sharedPage.locator('[data-test-offers-list]')).toContainText(tierName); + await expect(sharedPage.locator('[data-test-offers-list]')).toContainText(offerName); + }); }); test('Can create additional Tier', async ({sharedPage}) => { diff --git a/ghost/core/test/e2e-browser/portal/offers.spec.js b/ghost/core/test/e2e-browser/portal/offers.spec.js index 06a9a9ef11..e63569ed73 100644 --- a/ghost/core/test/e2e-browser/portal/offers.spec.js +++ b/ghost/core/test/e2e-browser/portal/offers.spec.js @@ -18,7 +18,7 @@ test.describe('Portal', () => { }); // add a new offer with free trial - const {offerName, offerLink} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'Black Friday Special', tierName, offerType: 'freeTrial', @@ -26,10 +26,14 @@ test.describe('Portal', () => { }); // check that offer was added in the offer list screen - await sharedPage.goto('/ghost/#/settings/offers'); - await expect(sharedPage.getByTestId('offers')).toContainText(offerName); + await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible(); - await sharedPage.goto(offerLink); + // open offer details page + await sharedPage.locator(`[data-test-offer="${offerName}"] a`).first().click(); + + // fetch offer url from portal settings and open it + const portalUrl = await sharedPage.locator('[data-test-input="offer-portal-url"]').inputValue(); + await sharedPage.goto(portalUrl); const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]'); @@ -62,15 +66,14 @@ test.describe('Portal', () => { await sharedPage.goto('/ghost'); await sharedPage.locator('.gh-nav a[href="#/members/"]').click(); - // // 1 member, should be Testy, on Portal Tier + // 1 member, should be Testy, on Portal Tier await expect(sharedPage.getByRole('link', {name: 'Testy McTesterson testy+trial@example.com'}), 'Should have 1 paid member').toBeVisible(); await expect(sharedPage.getByRole('link', {name: tierName}), `Paid member should be on ${tierName}`).toBeVisible(); - // // Ensure the offer redemption count was bumped - await sharedPage.goto('/ghost/#/settings/offers'); - // await sharedPage.locator('.gh-nav a[href="#/offers/"]').click(); - const locator = sharedPage.locator(`[data-test-offer="${offerName}"]`); - await expect(locator).toContainText('1 redemption'); + // Ensure the offer redemption count was bumped + await sharedPage.locator('.gh-nav a[href="#/offers/"]').click(); + const locator = sharedPage.locator(`[data-test-offer="${offerName}"]`).locator('[data-test-list="redemption-count"]').locator('span'); + await expect(locator).toContainText('1'); }); test('Creates and uses a one-time discount Offer', async ({sharedPage}) => { @@ -87,7 +90,7 @@ test.describe('Portal', () => { }); // Creates a one-time discount offer for 10% off - const {offerName, offerLink} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'Black Friday Special', tierName: tierName, offerType: 'discount', @@ -95,13 +98,14 @@ test.describe('Portal', () => { }); // check that offer was added in the offer list screen - await sharedPage.goto('/ghost/#/settings/offers'); - await expect(sharedPage.getByTestId('offers')).toContainText(offerName); + await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible(); + // open offer details page - // await sharedPage.locator(`[data-test-offer="${offerName}"] a`).first().click(); + await sharedPage.locator(`[data-test-offer="${offerName}"] a`).first().click(); // fetch offer url from portal settings and open it - await sharedPage.goto(offerLink); + const portalUrl = await sharedPage.locator('[data-test-input="offer-portal-url"]').inputValue(); + await sharedPage.goto(portalUrl); const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]'); @@ -151,7 +155,7 @@ test.describe('Portal', () => { }); // Creates a one-time discount offer for 10% off - const {offerName, offerLink} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'Black Friday Special', tierName: tierName, offerType: 'discount', @@ -161,10 +165,14 @@ test.describe('Portal', () => { }); // check that offer was added in the offer list screen - await sharedPage.goto('/ghost/#/settings/offers'); - await expect(sharedPage.getByTestId('offers')).toContainText(offerName); + await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible(); - await sharedPage.goto(offerLink); + // open offer details page + await sharedPage.locator(`[data-test-offer="${offerName}"] a`).first().click(); + + // fetch offer url from portal settings and open it + const portalUrl = await sharedPage.locator('[data-test-input="offer-portal-url"]').inputValue(); + await sharedPage.goto(portalUrl); const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]'); @@ -215,7 +223,7 @@ test.describe('Portal', () => { }); // Creates a one-time discount offer for 10% off - const {offerName, offerLink} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'Black Friday Special', tierName: tierName, offerType: 'discount', @@ -224,10 +232,14 @@ test.describe('Portal', () => { }); // check that offer was added in the offer list screen - await sharedPage.goto('/ghost/#/settings/offers'); - await expect(sharedPage.getByTestId('offers')).toContainText(offerName); + await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible(); - await sharedPage.goto(offerLink); + // open offer details page + await sharedPage.locator(`[data-test-offer="${offerName}"] a`).first().click(); + + // fetch offer url from portal settings and open it + const portalUrl = await sharedPage.locator('[data-test-input="offer-portal-url"]').inputValue(); + await sharedPage.goto(portalUrl); const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]'); const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]'); @@ -274,7 +286,7 @@ test.describe('Portal', () => { }); // Create an offer. This will be archived - const {offerLink} = await createOffer(sharedPage, { + const offerName = await createOffer(sharedPage, { name: 'To be archived', tierName: tierName, offerType: 'discount', @@ -288,8 +300,18 @@ test.describe('Portal', () => { offerType: 'discount', amount: 10 }); + + // Check if the offer appears in the archive list + await sharedPage.locator('.gh-contentfilter-menu-trigger').click(); + await sharedPage.getByRole('option', {name: 'Archived offers'}).click(); + await expect(sharedPage.getByRole('link', {name: offerName}), 'Should have an archived offer').toBeVisible(); + + // Go to the offer and grab the offer URL + await sharedPage.locator('.gh-offers-list .gh-list-row').filter({hasText: offerName}).click(); + const portalUrl = await sharedPage.locator('input#url').inputValue(); + // Open the offer URL and make sure portal popup doesn't load - await sharedPage.goto(offerLink); + await sharedPage.goto(portalUrl); const portalPopup = await sharedPage.locator('[data-testid="portal-popup-frame"]').isVisible(); await expect(portalPopup).toBeFalsy(); }); diff --git a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js index 67e69423c7..4247090356 100644 --- a/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js +++ b/ghost/core/test/e2e-browser/utils/e2e-browser-utils.js @@ -247,71 +247,72 @@ const createTier = async (page, {name, monthlyPrice, yearlyPrice, trialDays}, en * @param {string} [options.discountType] * @param {number} [options.discountDuration] * @param {number} options.amount - * @returns {Promise} Unique offer name + * @returns {Promise} Unique offer name */ - const createOffer = async (page, {name, tierName, offerType, amount, discountType = null, discountDuration = 3}) => { await page.goto('/ghost'); - await page.locator('[data-test-nav="settings"]').click(); + await page.locator('.gh-nav a[href="#/offers/"]').click(); // Keep offer names unique & <= 40 characters let offerName = `${name} (${new ObjectID().toHexString().slice(0, 40 - name.length - 3)})`; - // Tiers request can take time, so waiting until there is no connections before interacting with them - await page.waitForLoadState('networkidle'); - const hasExistingOffers = await page.getByTestId('offers').getByRole('button', {name: 'Manage offers'}).isVisible(); - const isCTA = await page.getByTestId('offers').getByRole('button', {name: 'Add offer'}).isVisible(); // Archive other offers to keep the list tidy // We only need 1 offer to be active at a time // Either the list of active offers loads, or the CTA when no offers exist - if (hasExistingOffers && !isCTA) { - await page.getByTestId('offers').getByRole('button', {name: 'Manage offers'}).click(); - await page.waitForLoadState('networkidle'); - - // Selector for the elements with data-testid 'offer-item' - // const offerItemsSelector = '[data-testid="offer-item"]'; - await page.getByTestId('offer-item').nth(0).click(); + while (await Promise.race([ + page.locator('.gh-offers-list .gh-list-header').filter({hasText: 'active'}).waitFor({state: 'visible', timeout: 1000}).then(() => true), + page.locator('.gh-offers-list-cta').waitFor({state: 'visible', timeout: 1000}).then(() => false) + ]).catch(() => false)) { + const listItem = page.locator('.gh-offers-list .gh-list-row:not(.header)').first(); + await listItem.locator('a[href^="#/offers/"]').last().click(); await page.getByRole('button', {name: 'Archive offer'}).click(); + await page + .locator('.modal-content') + .filter({hasText: 'Archive offer'}) + .first() + .getByRole('button', {name: 'Archive'}) + .click(); - const confirmModal = page.getByTestId('confirmation-modal'); - await confirmModal.getByRole('button', {name: 'Archive'}).click(); + // TODO: Use a more resilient selector + const statusDropdown = await page.getByRole('button', {name: 'Archived offers'}); + await statusDropdown.waitFor({ + state: 'visible', + timeout: 1000 + }); + await statusDropdown.click(); + await page.getByRole('option', {name: 'Active offers'}).click(); } - if (isCTA) { - await page.getByTestId('offers').getByRole('button', {name: 'Add offer'}).click(); - } else { - await page.getByText('New offer').click(); - } - - // const newOfferButton = await page.getByTestId('offers').getByRole('button', {name: 'Add offer'}) || await page.getByTestId('offers').getByRole('button', {name: 'New offer'}); - // await page.getByTestId('offers').getByRole('button', {name: 'Add offer'}).click(); - // await newOfferButton.click(); - await page.getByLabel('Offer name').fill(offerName); + await page.getByRole('link', {name: 'New offer'}).click(); + await page.locator('input#name').fill(offerName); if (offerType === 'freeTrial') { - // await page.getByRole('button', {name: 'Free trial Give free access for a limited time.'}).click(); - await page.getByText('Give free access for a limited time').click(); - await page.getByLabel('Trial duration').fill(`${amount}`); + await page.getByRole('button', {name: 'Free trial Give free access for a limited time.'}).click(); + await page.locator('input#trial-duration').fill(`${amount}`); } else if (offerType === 'discount') { - await page.getByLabel('Amount off').fill(`${amount}`); + await page.locator('input#amount').fill(`${amount}`); if (discountType === 'multiple-months') { - await chooseOptionInSelect(page.getByTestId('duration-select-offers'), `Multiple-months`); - await page.getByLabel('Duration in months').fill(discountDuration.toString()); - // await page.locator('[data-test-select="offer-duration"]').selectOption('repeating'); - // await page.locator('input#duration-months').fill(discountDuration.toString()); + await page.locator('[data-test-select="offer-duration"]').selectOption('repeating'); + await page.locator('input#duration-months').fill(discountDuration.toString()); } if (discountType === 'forever') { - await chooseOptionInSelect(page.getByTestId('duration-select-offers'), `Forever`); + await page.locator('[data-test-select="offer-duration"]').selectOption('forever'); } } - await chooseOptionInSelect(page.getByTestId('tier-cadence-select-offers'), `${tierName} - Monthly`); - await page.getByRole('button', {name: 'Publish'}).click(); - await page.waitForLoadState('networkidle'); - const offerLink = await page.locator('input[name="offer-url"]').inputValue(); + const priceId = await page.locator(`.gh-select-product-cadence>select>option`).getByText(`${tierName} - Monthly`).getAttribute('value'); + await page.locator('.gh-select-product-cadence>select').selectOption(priceId); - return {offerName, offerLink}; + await page.getByRole('button', {name: 'Save'}).click(); + // Wait for the "Saved" button, ensures that next clicks don't trigger the unsaved work modal + await page.getByRole('button', {name: 'Saved'}).waitFor({ + state: 'visible', + timeout: 10000 + }); + await page.locator('.gh-nav a[href="#/offers/"]').click(); + + return offerName; }; const fillInputIfExists = async (page, selector, value) => { @@ -492,11 +493,6 @@ const generateStripeIntegrationToken = async (accountId) => { })).toString('base64'); }; -const chooseOptionInSelect = async (select, optionText) => { - await select.click(); - await select.page().locator('[data-testid="select-option"]', {hasText: optionText}).click(); -}; - module.exports = { setupGhost, setupStripe, @@ -513,6 +509,5 @@ module.exports = { completeStripeSubscription, impersonateMember, goToMembershipPage, - openTierModal, - chooseOptionInSelect + openTierModal };