🎨 Added Offers to the new Settings (#19493)

no issue

- Removes flags for the new Offers in Admin X (Settings)
- Removes old Offers from the sidebar.
- See a new version of Offers in Settings. 🎨
This commit is contained in:
Ronald Langeveld 2024-01-18 12:56:08 +00:00 committed by GitHub
parent 14bf2df834
commit c7d7b883cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 292 additions and 352 deletions

View File

@ -27,9 +27,11 @@ export interface ButtonProps extends Omit<HTMLProps<HTMLButtonElement>, 'label'
loadingIndicatorColor?: LoadingIndicatorColor;
outlineOnMobile?: boolean;
onClick?: (e?:React.MouseEvent<HTMLElement>) => void;
testId?: string;
}
const Button: React.FC<ButtonProps> = ({
testId,
size = 'md',
label = '',
hideLabel = false,
@ -142,6 +144,7 @@ const Button: React.FC<ButtonProps> = ({
</>;
const buttonElement = React.createElement(tag, {className: className,
'data-testid': testId,
disabled: disabled,
type: 'button',
onClick: onClick,

View File

@ -416,7 +416,7 @@ const Modal: React.FC<ModalProps> = ({
(<header className={headerClasses}>
{title && <Heading level={3}>{title}</Heading>}
<div className={`${topRightContent !== 'close' && 'md:!invisible md:!hidden'} ${hideXOnMobile && 'hidden'} absolute right-6 top-6`}>
<Button className='-m-2 cursor-pointer p-2 opacity-50 hover:opacity-100' icon='close' iconColorClass='text-black dark:text-white' size='sm' unstyled onClick={removeModal} />
<Button className='-m-2 cursor-pointer p-2 opacity-50 hover:opacity-100' icon='close' iconColorClass='text-black dark:text-white' size='sm' testId='close-modal' unstyled onClick={removeModal} />
</div>
</header>)
:

View File

@ -72,8 +72,7 @@ const defaultLabFlags = {
outboundLinkTagging: false,
announcementBar: false,
signupForm: false,
members: false,
adminXOffers: false
members: false
};
// Inject defaultLabFlags into responseFixtures.settings and config

View File

@ -35,7 +35,7 @@ const Sidebar: React.FC = () => {
const {updateRoute} = useRouting();
const searchInputRef = useRef<HTMLInputElement | null>(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 = () => {
<SettingNavSection isVisible={checkVisible(Object.values(growthSearchKeywords).flat())} title="Growth">
{hasRecommendations && <NavItem icon='heart' keywords={growthSearchKeywords.recommendations} navid='recommendations' title="Recommendations" onClick={handleSectionClick} />}
<NavItem icon='emailfield' keywords={growthSearchKeywords.embedSignupForm} navid='embed-signup-form' title="Embeddable signup form" onClick={handleSectionClick} />
{hasOffersLabs && hasStripeEnabled && <NavItem icon='discount' keywords={growthSearchKeywords.offers} navid='offers' title="Offers" onClick={handleSectionClick} />}
{hasStripeEnabled && <NavItem icon='discount' keywords={growthSearchKeywords.offers} navid='offers' title="Offers" onClick={handleSectionClick} />}
{hasTipsAndDonations && <NavItem icon='piggybank' keywords={growthSearchKeywords.tips} navid='tips-or-donations' title="Tips or donations" onClick={handleSectionClick} />}
</SettingNavSection>

View File

@ -47,10 +47,6 @@ 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',

View File

@ -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 = () => {
<SearchableSection keywords={Object.values(searchKeywords).flat()} title='Growth'>
{hasRecommendations && <Recommendations keywords={searchKeywords.recommendations} />}
<EmbedSignupForm keywords={searchKeywords.embedSignupForm} />
{hasOffersLabs && hasStripeEnabled && <Offers keywords={searchKeywords.offers} />}
{hasStripeEnabled && <Offers keywords={searchKeywords.offers} />}
{hasTipsAndDonations && <TipsOrDonations keywords={searchKeywords.tips} />}
</SearchableSection>
);

View File

@ -1,5 +1,4 @@
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';
@ -11,7 +10,6 @@ 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
@ -211,6 +209,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
<Select
options={tierOptions}
selectedOption={selectedTier}
testId='tier-cadence-select-offers'
title='Tier — Cadence'
onSelect={(e) => {
if (e) {
@ -241,6 +240,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
controlClasses={{menu: 'w-20 right-0'}}
options={amountOptions}
selectedOption={overrides.type === 'percent' ? amountOptions[0] : amountOptions[1]}
testId='amount-type-select-offers'
onSelect={(e) => {
handleAmountTypeChange(e?.value || '');
}}
@ -250,6 +250,7 @@ const Sidebar: React.FC<SidebarProps> = ({tierOptions,
<Select
options={filteredDurationOptions}
selectedOption={filteredDurationOptions.find(option => option.value === overrides.duration)}
testId='duration-select-offers'
title='Duration'
onSelect={e => handleDurationChange(e?.value || '')}
/>
@ -316,9 +317,7 @@ const AddOfferModal = () => {
];
const [href, setHref] = useState<string>('');
const modal = useModal();
const {updateRoute} = useRouting();
const hasOffers = useFeatureFlag('adminXOffers');
const {data: {tiers} = {}} = useBrowseTiers();
const activeTiers = getPaidActiveTiers(tiers || []);
const tierCadenceOptions = getTiersCadences(activeTiers);
@ -556,13 +555,6 @@ const AddOfferModal = () => {
}));
};
useEffect(() => {
if (!hasOffers) {
modal.remove();
updateRoute('');
}
}, [hasOffers, modal, updateRoute]);
const cancelAddOffer = () => {
if (allOffers.length > 0) {
updateRoute('offers/edit');

View File

@ -1,6 +1,6 @@
import NiceModal, {useModal} from '@ebay/nice-modal-react';
import NiceModal 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,21 +177,12 @@ 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<string>('');
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({

View File

@ -103,7 +103,7 @@ const OfferSuccess: React.FC<{id: string}> = ({id}) => {
<p className='mt-3 max-w-[510px] text-[1.6rem]'>You can share the link anywhere. In your newsletter, social media, a podcast, or in-person. It all just works.</p>
<div className='mt-8 flex w-full max-w-md flex-col gap-8'>
<div className='flex flex-col-reverse gap-2'>
<TextField type='url' value={offerLink} disabled />
<TextField name='offer-url' type='url' value={offerLink} disabled />
<Button color='green' label={isCopied ? 'Copied!' : 'Copy link'} fullWidth onClick={handleCopyClick} />
</div>
<div className='flex items-center gap-4 text-xs font-medium before:h-px before:grow before:bg-grey-300 before:content-[""] after:h-px after:grow after:bg-grey-300 after:content-[""] dark:before:bg-grey-800 dark:after:bg-grey-800'>OR</div>

View File

@ -1,4 +1,3 @@
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';
@ -10,11 +9,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';
@ -61,7 +60,6 @@ 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;
}
@ -94,7 +92,6 @@ 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'
@ -123,13 +120,6 @@ 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');
@ -145,7 +135,6 @@ 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));
}
});
@ -164,7 +153,6 @@ 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) => {
@ -179,7 +167,7 @@ export const OffersIndexModal = () => {
const {discountOffer, originalPriceWithCurrency, updatedPriceWithCurrency} = getOfferDiscount(offer.type, offer.amount, offer.cadence, offer.currency || 'USD', offerTier);
return (
<tr className={`group relative scale-100 border-b border-b-grey-200 dark:border-grey-800`}>
<tr className={`group relative scale-100 border-b border-b-grey-200 dark:border-grey-800`} data-testid="offer-item">
<td className={`${isTierArchived ? 'opacity-50' : ''} p-0`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5 pl-0`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-semibold'>{offer?.name}</span><br /><span className='text-sm text-grey-700'>{offerTier.name} {getOfferCadence(offer.cadence)}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='text-[1.3rem] font-medium uppercase'>{discountOffer}</span><br /><span className='text-grey-700'>{offer.type !== 'trial' ? getOfferDuration(offer.duration) : 'Trial period'}</span></a></td>
<td className={`${isTierArchived ? 'opacity-50' : ''} whitespace-nowrap p-0 text-sm`}><a className={`block ${isTierArchived ? 'cursor-default select-none' : 'cursor-pointer'} p-5`} onClick={!isTierArchived ? () => handleOfferEdit(offer?.id ? offer.id : '') : () => {}}><span className='font-medium'>{updatedPriceWithCurrency}</span> {offer.type !== 'trial' ? <span className='relative text-xs text-grey-700 before:absolute before:-inset-x-0.5 before:top-1/2 before:rotate-[-20deg] before:border-t before:content-[""]'>{originalPriceWithCurrency}</span> : null}</a></td>

View File

@ -1,11 +1,11 @@
import {expect, test} from '@playwright/test';
import {globalDataRequests} from '../../utils/acceptance';
import {mockApi, responseFixtures, settingsWithStripe, toggleLabsFlag} from '@tryghost/admin-x-framework/test/acceptance';
import {mockApi, responseFixtures, settingsWithStripe} 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: {

View File

@ -54,8 +54,6 @@ 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/);

View File

@ -90,9 +90,6 @@ 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);

View File

@ -351,3 +351,4 @@ 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

View File

@ -117,11 +117,6 @@
{{/if}}
</li>
{{#if this.settings.paidMembersEnabled}}
<li>
<LinkTo @route="offers" @current-when="offers offer offer.new" @alt="Offers" data-test-nav="offers">{{svg-jar "percentage"}}Offers</LinkTo>
</li>
{{/if}}
{{/if}}
{{#if (feature "adminXDemo")}}
<li>

View File

@ -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'});

View File

@ -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;
// }
// }

View File

@ -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');
// }
// }
// }

View File

@ -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'
// };
// }
// }

View File

@ -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');
// });
// });
// });

View File

@ -44,7 +44,7 @@ const ALPHA_FEATURES = [
'tipsAndDonations',
'importMemberTier',
'lexicalIndicators',
'adminXOffers',
// 'adminXOffers',
'filterEmailDisabled',
'adminXDemo',
'newEmailAddresses',

View File

@ -23,19 +23,16 @@ test.describe('Admin', () => {
monthlyPrice: 5,
yearlyPrice: 50
});
const offerName = await createOffer(sharedPage, {
const {offerName} = await createOffer(sharedPage, {
name: 'Get 5% Off!',
tierName,
offerType: 'discount',
amount: 5
offerType: 'freeTrial',
amount: 14
});
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);
});
await sharedPage.goto('/ghost/');
await sharedPage.goto('/ghost/#/settings/offers');
await expect(sharedPage.getByTestId('offers')).toContainText(offerName);
});
test('Can create additional Tier', async ({sharedPage}) => {

View File

@ -18,7 +18,7 @@ test.describe('Portal', () => {
});
// add a new offer with free trial
const offerName = await createOffer(sharedPage, {
const {offerName, offerLink} = await createOffer(sharedPage, {
name: 'Black Friday Special',
tierName,
offerType: 'freeTrial',
@ -26,14 +26,10 @@ test.describe('Portal', () => {
});
// check that offer was added in the offer list screen
await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible();
await sharedPage.goto('/ghost/#/settings/offers');
await expect(sharedPage.getByTestId('offers')).toContainText(offerName);
// 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);
await sharedPage.goto(offerLink);
const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]');
@ -66,14 +62,15 @@ 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.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');
// // 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');
});
test('Creates and uses a one-time discount Offer', async ({sharedPage}) => {
@ -90,7 +87,7 @@ test.describe('Portal', () => {
});
// Creates a one-time discount offer for 10% off
const offerName = await createOffer(sharedPage, {
const {offerName, offerLink} = await createOffer(sharedPage, {
name: 'Black Friday Special',
tierName: tierName,
offerType: 'discount',
@ -98,14 +95,13 @@ test.describe('Portal', () => {
});
// check that offer was added in the offer list screen
await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible();
await sharedPage.goto('/ghost/#/settings/offers');
await expect(sharedPage.getByTestId('offers')).toContainText(offerName);
// 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
const portalUrl = await sharedPage.locator('[data-test-input="offer-portal-url"]').inputValue();
await sharedPage.goto(portalUrl);
await sharedPage.goto(offerLink);
const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]');
@ -155,7 +151,7 @@ test.describe('Portal', () => {
});
// Creates a one-time discount offer for 10% off
const offerName = await createOffer(sharedPage, {
const {offerName, offerLink} = await createOffer(sharedPage, {
name: 'Black Friday Special',
tierName: tierName,
offerType: 'discount',
@ -165,14 +161,10 @@ test.describe('Portal', () => {
});
// check that offer was added in the offer list screen
await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible();
await sharedPage.goto('/ghost/#/settings/offers');
await expect(sharedPage.getByTestId('offers')).toContainText(offerName);
// 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);
await sharedPage.goto(offerLink);
const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]');
@ -223,7 +215,7 @@ test.describe('Portal', () => {
});
// Creates a one-time discount offer for 10% off
const offerName = await createOffer(sharedPage, {
const {offerName, offerLink} = await createOffer(sharedPage, {
name: 'Black Friday Special',
tierName: tierName,
offerType: 'discount',
@ -232,14 +224,10 @@ test.describe('Portal', () => {
});
// check that offer was added in the offer list screen
await expect(sharedPage.locator(`[data-test-offer="${offerName}"]`), 'Should have free-trial offer').toBeVisible();
await sharedPage.goto('/ghost/#/settings/offers');
await expect(sharedPage.getByTestId('offers')).toContainText(offerName);
// 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);
await sharedPage.goto(offerLink);
const portalTriggerButton = sharedPage.frameLocator('[data-testid="portal-trigger-frame"]').locator('[data-testid="portal-trigger-button"]');
const portalFrame = sharedPage.frameLocator('[data-testid="portal-popup-frame"]');
@ -286,7 +274,7 @@ test.describe('Portal', () => {
});
// Create an offer. This will be archived
const offerName = await createOffer(sharedPage, {
const {offerLink} = await createOffer(sharedPage, {
name: 'To be archived',
tierName: tierName,
offerType: 'discount',
@ -300,18 +288,8 @@ 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(portalUrl);
await sharedPage.goto(offerLink);
const portalPopup = await sharedPage.locator('[data-testid="portal-popup-frame"]').isVisible();
await expect(portalPopup).toBeFalsy();
});

View File

@ -247,72 +247,71 @@ const createTier = async (page, {name, monthlyPrice, yearlyPrice, trialDays}, en
* @param {string} [options.discountType]
* @param {number} [options.discountDuration]
* @param {number} options.amount
* @returns {Promise<string>} Unique offer name
* @returns {Promise<object>} Unique offer name
*/
const createOffer = async (page, {name, tierName, offerType, amount, discountType = null, discountDuration = 3}) => {
await page.goto('/ghost');
await page.locator('.gh-nav a[href="#/offers/"]').click();
await page.locator('[data-test-nav="settings"]').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
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();
if (hasExistingOffers && !isCTA) {
await page.getByTestId('offers').getByRole('button', {name: 'Manage offers'}).click();
await page.waitForLoadState('networkidle');
// 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();
// Selector for the elements with data-testid 'offer-item'
// const offerItemsSelector = '[data-testid="offer-item"]';
await page.getByTestId('offer-item').nth(0).click();
await page.getByRole('button', {name: 'Archive offer'}).click();
const confirmModal = page.getByTestId('confirmation-modal');
await confirmModal.getByRole('button', {name: 'Archive'}).click();
}
await page.getByRole('link', {name: 'New offer'}).click();
await page.locator('input#name').fill(offerName);
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);
if (offerType === 'freeTrial') {
await page.getByRole('button', {name: 'Free trial Give free access for a limited time.'}).click();
await page.locator('input#trial-duration').fill(`${amount}`);
// 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}`);
} else if (offerType === 'discount') {
await page.locator('input#amount').fill(`${amount}`);
await page.getByLabel('Amount off').fill(`${amount}`);
if (discountType === 'multiple-months') {
await page.locator('[data-test-select="offer-duration"]').selectOption('repeating');
await page.locator('input#duration-months').fill(discountDuration.toString());
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());
}
if (discountType === 'forever') {
await page.locator('[data-test-select="offer-duration"]').selectOption('forever');
await chooseOptionInSelect(page.getByTestId('duration-select-offers'), `Forever`);
}
}
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);
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();
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;
return {offerName, offerLink};
};
const fillInputIfExists = async (page, selector, value) => {
@ -493,6 +492,11 @@ 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,
@ -509,5 +513,6 @@ module.exports = {
completeStripeSubscription,
impersonateMember,
goToMembershipPage,
openTierModal
openTierModal,
chooseOptionInSelect
};