Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ const features: Feature[] = [{
title: 'Featurebase Feedback',
description: 'Display a Feedback menu item in the admin sidebar. Requires the new admin experience.',
flag: 'featurebaseFeedback'
}, {
title: 'Transistor',
description: 'Enable Transistor podcast integration',
flag: 'transistor'
}, {
title: 'Verification flow',
description: 'Enable new Email verification webhook-based flow',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';

import MemberEmailEditor from './member-email-editor';
import useFeatureFlag from '../../../../hooks/use-feature-flag';
import {Hint, Button as LegacyButton, Modal, TextField} from '@tryghost/admin-x-design-system';
import {confirmIfDirty} from '@tryghost/admin-x-design-system';
import {useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
Expand Down Expand Up @@ -125,7 +124,6 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
const {settings} = useGlobalData();
const [siteTitle] = getSettingValues<string>(settings, ['title']);
const {resolvedSenderName, resolvedSenderEmail, resolvedReplyToEmail, hasDistinctReplyTo} = useWelcomeEmailSenderDetails(automatedEmail);
const welcomeEmailEditorEnabled = useFeatureFlag('welcomeEmailEditor');
const emailTypeLabel = emailType === 'paid' ? 'Paid' : 'Free';
const modalTitle = `${emailTypeLabel} members welcome email`;

Expand Down Expand Up @@ -220,99 +218,6 @@ const WelcomeEmailModal = NiceModal.create<WelcomeEmailModalProps>(({emailType =
}
}, [setFormState, updateForm]);

if (!welcomeEmailEditorEnabled) {
return (
<Modal
afterClose={() => {
updateRoute('memberemails');
}}
dirty={isDirty}
footer={false}
header={false}
testId='welcome-email-modal'
width={672}
>
<div className='-mx-8 flex h-[calc(100vh-16vmin)] flex-col overflow-y-auto dark:bg-grey-975!'>
<div className='sticky top-0 z-10 flex flex-col gap-2 border-b border-grey-100 bg-white p-5 dark:border-grey-900 dark:bg-grey-975'>
<div className='mb-2 flex items-center justify-between'>
<h3 className='text-lg font-semibold'>{modalTitle}</h3>
<div className='flex items-center gap-2'>
<div ref={dropdownRef} className='relative'>
<LegacyButton
className='border border-grey-200 font-semibold hover:border-grey-300 hover:bg-white! dark:border-grey-900 dark:hover:border-grey-800 dark:hover:bg-grey-950!'
color="clear"
icon='send'
label="Test"
onClick={() => setShowTestDropdown(!showTestDropdown)}
/>
{showTestDropdown && (
<TestEmailDropdown automatedEmailId={automatedEmail.id} lexical={formState.lexical} subject={formState.subject} validateForm={validate} onClose={() => setShowTestDropdown(false)} />
)}
</div>
<LegacyButton
color={okProps.color}
disabled={okProps.disabled}
label={saveButtonLabel}
onClick={async () => await handleSave({fakeWhenUnchanged: true})}
/>
</div>
</div>
<div className='flex items-center'>
<div className='w-20 shrink-0 font-semibold'>From:</div>
<div className='min-w-0 grow'>
<span className='flex gap-1 truncate whitespace-nowrap'>
<span>{resolvedSenderName}</span>
<span className='text-grey-700 dark:text-grey-400'>{`<${resolvedSenderEmail}>`}</span>
</span>
</div>
</div>
{hasDistinctReplyTo && (
<div className='flex items-center py-0.5'>
<div className='w-20 shrink-0 font-semibold'>Reply-to:</div>
<div className='grow text-grey-700 dark:text-grey-400'>
{resolvedReplyToEmail}
</div>
</div>
)}
<div className='flex items-center'>
<div className='w-20 shrink-0 font-semibold'>Subject:</div>
<div className='grow'>
<TextField
className='w-full'
error={Boolean(errors.subject)}
hint={errors.subject || ''}
maxLength={300}
placeholder={`Welcome to ${siteTitle}`}
value={formState.subject}
onChange={e => updateForm(state => ({...state, subject: e.target.value}))}
/>
</div>
</div>
</div>
<div className='flex grow flex-col bg-grey-50 p-8 dark:bg-grey-975'>
<div
className={`mx-auto flex w-full max-w-[600px] grow flex-col rounded border bg-white p-8 shadow-sm dark:bg-grey-950/25 dark:shadow-none ${errors.lexical ? 'border-red' : 'border-grey-200 dark:border-grey-925'}`}
data-testid='welcome-email-editor'
onFocus={() => {
hasEditorBeenFocused.current = true;
}}
>
<MemberEmailEditor
key={automatedEmail?.id || 'new'}
className='welcome-email-editor'
placeholder='Write your welcome email content...'

value={automatedEmail?.lexical || ''}
onChange={handleEditorChange}
/>
</div>
{errors.lexical && <Hint className='mt-2 mr-auto ml-8 max-w-[600px]' color='red'>{errors.lexical}</Hint>}
</div>
</div>
</Modal>
);
}

return (
<Modal
afterClose={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,10 @@ const newslettersRequest = {
browseNewslettersLimit: {method: 'GET', path: '/newsletters/?filter=status%3Aactive&limit=1', response: responseFixtures.newsletters}
};

const configWithWelcomeEmailEditorEnabled = {
const configWithTenorEnabled = {
...responseFixtures.config,
config: {
...responseFixtures.config.config,
labs: {
...responseFixtures.config.config.labs,
welcomeEmailEditor: true
}
}
};

const configWithWelcomeEmailEditorAndTenorEnabled = {
...configWithWelcomeEmailEditorEnabled,
config: {
...configWithWelcomeEmailEditorEnabled.config,
tenor: {
googleApiKey: 'test-tenor-key',
contentFilter: 'off'
Expand Down Expand Up @@ -249,7 +238,7 @@ test.describe('Member emails settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture},
fetchOembed: {
method: 'GET',
Expand Down Expand Up @@ -288,7 +277,7 @@ test.describe('Member emails settings', async () => {
const {lastApiRequests} = await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture},
fetchOembed: {
method: 'GET',
Expand Down Expand Up @@ -337,7 +326,7 @@ test.describe('Member emails settings', async () => {
await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
}});

Expand Down Expand Up @@ -365,7 +354,7 @@ test.describe('Member emails settings', async () => {
await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
}});

Expand Down Expand Up @@ -406,7 +395,7 @@ test.describe('Member emails settings', async () => {
await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: responseFixtures.config},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
}});

Expand Down Expand Up @@ -449,7 +438,7 @@ test.describe('Member emails settings', async () => {
await mockApi({page, requests: {
...globalDataRequests,
...newslettersRequest,
browseConfig: {method: 'GET', path: '/config/', response: configWithWelcomeEmailEditorAndTenorEnabled},
browseConfig: {method: 'GET', path: '/config/', response: configWithTenorEnabled},
browseAutomatedEmails: {method: 'GET', path: '/automated_emails/', response: automatedEmailsFixture}
}});

Expand Down
2 changes: 1 addition & 1 deletion apps/portal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
"version": "2.66.9",
"version": "2.66.10",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
16 changes: 8 additions & 8 deletions apps/portal/src/components/pages/offer-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,20 +445,20 @@ export default class OfferPage extends React.Component {

if (offer.type === 'fixed') {
return (
<h5 className="gh-portal-discount-label">{t('{amount} off', {
<h5 className="gh-portal-discount-label" data-testid="offer-discount-label">{t('{amount} off', {
amount: `${getCurrencySymbol(offer.currency)}${offer.amount / 100}`
})}</h5>
);
}

if (offer.type === 'trial') {
return (
<h5 className="gh-portal-discount-label">{t('{amount} days free', {amount: offer.amount})}</h5>
<h5 className="gh-portal-discount-label" data-testid="offer-discount-label">{t('{amount} days free', {amount: offer.amount})}</h5>
);
}

return (
<h5 className="gh-portal-discount-label">{t('{amount} off', {amount: offer.amount + '%'})}</h5>
<h5 className="gh-portal-discount-label" data-testid="offer-discount-label">{t('{amount} off', {amount: offer.amount + '%'})}</h5>
);
}

Expand Down Expand Up @@ -544,15 +544,15 @@ export default class OfferPage extends React.Component {
}
if (discountDuration === 'trial') {
return (
<p className="footnote">{t('Try free for {amount} days, then {originalPrice}.', {
<p className="footnote" data-testid="offer-message">{t('Try free for {amount} days, then {originalPrice}.', {
amount: offer.amount,
originalPrice: originalPrice,
interpolation: {escapeValue: false}
})} <span className="gh-portal-cancel">{t('Cancel anytime.')}</span></p>
);
}
return (
<p className="footnote">{offerLabel} {useRenewsLabel ? renewsLabel : ''}</p>
<p className="footnote" data-testid="offer-message">{offerLabel} {useRenewsLabel ? renewsLabel : ''}</p>
);
}

Expand All @@ -573,7 +573,7 @@ export default class OfferPage extends React.Component {
if (offer.type === 'trial') {
return (
<div className="gh-portal-product-card-pricecontainer offer-type-trial">
<div className="gh-portal-product-price">
<div className="gh-portal-product-price" data-testid="offer-updated-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
</div>
Expand All @@ -582,7 +582,7 @@ export default class OfferPage extends React.Component {
}
return (
<div className="gh-portal-product-card-pricecontainer">
<div className="gh-portal-product-price">
<div className="gh-portal-product-price" data-testid="offer-updated-price">
<span className={'currency-sign ' + currencyClass}>{getCurrencySymbol(price.currency)}</span>
<span className="amount">{formatNumber(this.renderRoundedPrice(updatedPrice))}</span>
</div>
Expand Down Expand Up @@ -657,7 +657,7 @@ export default class OfferPage extends React.Component {

<div className="gh-portal-offer-bar">
<div className="gh-portal-offer-title">
{(offer.display_title ? <h4>{offer.display_title}</h4> : <h4 className='placeholder'>{t('Black Friday')}</h4>)}
{(offer.display_title ? <h4 data-testid="offer-title">{offer.display_title}</h4> : <h4 className='placeholder' data-testid="offer-title">{t('Black Friday')}</h4>)}
{this.renderOfferTag()}
</div>
{(offer.display_description ? <p>{offer.display_description}</p> : '')}
Expand Down
5 changes: 4 additions & 1 deletion e2e/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,10 @@ export default tseslint.config([
{
files: ['tests/**/*.ts', 'helpers/playwright/**/*.ts', 'helpers/pages/**/*.ts'],
rules: {
...playwrightPlugin.configs.recommended.rules
...playwrightPlugin.configs.recommended.rules,
'playwright/expect-expect': ['warn', {
assertFunctionPatterns: ['^expect[A-Z].*']
}]
}
},

Expand Down
23 changes: 10 additions & 13 deletions e2e/helpers/pages/portal/offer-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ export class PortalOfferPage extends PortalPage {
readonly emailInput: Locator;
readonly submitButton: Locator;
readonly continueButton: Locator;
readonly offerTitle: Locator;
readonly discountLabel: Locator;
readonly offerMessage: Locator;
readonly updatedPrice: Locator;

constructor(page: Page) {
super(page);
Expand All @@ -14,14 +18,10 @@ export class PortalOfferPage extends PortalPage {
this.emailInput = this.portalFrame.getByRole('textbox', {name: 'Email'});
this.submitButton = this.portalFrame.getByRole('button', {name: /Continue|Start .* free trial|Retry/});
this.continueButton = this.portalFrame.getByRole('button', {name: 'Continue'});
}

headingWithText(text: string): Locator {
return this.portalFrame.getByRole('heading', {name: text});
}

text(text: string | RegExp): Locator {
return this.portalFrame.getByText(text);
this.offerTitle = this.portalFrame.getByTestId('offer-title');
this.discountLabel = this.portalFrame.getByTestId('offer-discount-label');
this.offerMessage = this.portalFrame.getByTestId('offer-message');
this.updatedPrice = this.portalFrame.getByTestId('offer-updated-price');
}

async fillAndSubmit(email: string, name?: string): Promise<void> {
Expand All @@ -39,12 +39,9 @@ export class PortalOfferPage extends PortalPage {
}
}

async waitForOfferPage(title?: string): Promise<void> {
async waitForOfferPage(): Promise<void> {
await this.waitForPortalToOpen();
await this.emailInput.waitFor({state: 'visible'});

if (title) {
await this.headingWithText(title).waitFor({state: 'visible'});
}
await this.offerTitle.waitFor({state: 'visible'});
}
}
32 changes: 30 additions & 2 deletions e2e/helpers/playwright/flows/tiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import {SettingsService} from '@/helpers/services/settings/settings-service';
import {TiersService} from '@/helpers/services/tiers/tiers-service';
import type {AdminTier, TierCreateInput} from '@/helpers/services/tiers/tiers-service';
import type {HttpClient} from '@/data-factory';
import type {StripeTestService} from '@/helpers/services/stripe';

export async function createPaidPortalTier(
request: HttpClient,
input: Omit<TierCreateInput, 'visibility'> & Partial<Pick<TierCreateInput, 'visibility'>>
input: Omit<TierCreateInput, 'visibility'> & Partial<Pick<TierCreateInput, 'visibility'>>,
opts?: {stripe?: StripeTestService}
): Promise<AdminTier> {
const tiersService = new TiersService(request);
const settingsService = new SettingsService(request);
Expand All @@ -17,5 +19,31 @@ export async function createPaidPortalTier(

await settingsService.setPortalPlans(['free', 'monthly', 'yearly']);

return tier;
if (!opts?.stripe) {
return tier;
}

// Tier creation returns before Ghost's async Stripe sync has finished.
// Stripe-backed tests that immediately use paid signup links need to wait
// until the product and prices exist in the fake Stripe state.
const timeoutMs = 10000;
const intervalMs = 250;
const startTime = Date.now();

while ((Date.now() - startTime) < timeoutMs) {
const product = opts.stripe.getProducts().find(item => item.name === tier.name);
const syncedPrices = product
? opts.stripe.getPrices().filter(item => item.product === product.id).length
: 0;

if (syncedPrices === 2) {
return tier;
}

await new Promise<void>((resolve) => {
setTimeout(resolve, intervalMs);
});
}

throw new Error(`Timed out waiting for Stripe sync for tier ${tier.name}`);
}
Loading