diff --git a/ghost/portal/package.json b/ghost/portal/package.json index 434e6d6440..55a8c42720 100644 --- a/ghost/portal/package.json +++ b/ghost/portal/package.json @@ -33,10 +33,12 @@ "eslintConfig": { "extends": [ "react-app", - "plugin:ghost/browser" + "plugin:ghost/browser", + "plugin:i18next/recommended" ], "plugins": [ - "ghost" + "ghost", + "i18next" ] }, "browserslist": { @@ -60,7 +62,7 @@ }, "devDependencies": { "@babel/eslint-parser": "7.21.8", - "@doist/react-interpolate": "0.4.0", + "@doist/react-interpolate": "0.4.1", "@sentry/react": "7.52.1", "@sentry/tracing": "7.53.0", "@testing-library/jest-dom": "5.16.5", @@ -73,6 +75,7 @@ "cross-fetch": "3.1.6", "eslint": "8.37.0", "eslint-config-react-app": "7.0.1", + "eslint-plugin-i18next": "^6.0.1", "jsdom": "22.0.0", "react": "17.0.2", "react-dom": "17.0.2", diff --git a/ghost/portal/src/components/Global.styles.js b/ghost/portal/src/components/Global.styles.js index 77c6cbec9a..ffa0009663 100644 --- a/ghost/portal/src/components/Global.styles.js +++ b/ghost/portal/src/components/Global.styles.js @@ -219,10 +219,16 @@ export const GlobalStyles = ` padding: 10vmin 28px; } - .gh-mobile-shortener { + .gh-desktop-only { + display: none; + } + } + + @media (min-width: 481px) { + .gh-mobile-only { display: none; } } `; -export default GlobalStyles; \ No newline at end of file +export default GlobalStyles; diff --git a/ghost/portal/src/components/Notification.js b/ghost/portal/src/components/Notification.js index 0ba6a3f440..4d77c54de6 100644 --- a/ghost/portal/src/components/Notification.js +++ b/ghost/portal/src/components/Notification.js @@ -26,6 +26,7 @@ const Styles = () => { }; const NotificationText = ({type, status, context}) => { + const t = context.t; const signinPortalLink = getPortalLink({page: 'signin', siteUrl: context.site.url}); const singupPortalLink = getPortalLink({page: 'signup', siteUrl: context.site.url}); @@ -33,16 +34,18 @@ const NotificationText = ({type, status, context}) => { const firstname = context.member.firstname || ''; return (

- Welcome back{(firstname ? ', ' + firstname : '')}!
You've successfully signed in. + {firstname ? t('Welcome back, {{name}}!', firstname) : t('Welcome back!')}
{t('You\'ve successfully signed in.')}

); } else if (type === 'signin' && status === 'error') { return (

- Could not sign in. Login link expired. Click here to retry + {t('Could not sign in. Login link expired.')} {t('Click here to retry')}

); } else if (type === 'signup' && status === 'success') { + // TODO: Wrap these strings with translation function + /* eslint-disable i18next/no-literal-string */ return (

You've successfully subscribed to
{context.site.title} @@ -54,41 +57,42 @@ const NotificationText = ({type, status, context}) => { You've successfully subscribed to
{context.site.title}

); + /* eslint-enable i18next/no-literal-string */ } else if (type === 'updateEmail' && status === 'success') { return (

- Success! Your email is updated. + {t('Success! Your email is updated.')}

); } else if (type === 'updateEmail' && status === 'error') { return (

- Could not update email! Invalid link. + {t('Could not update email! Invalid link.')}

); } else if (type === 'signup' && status === 'error') { return (

- Signup error: Invalid link
Click here to retry + {t('Signup error: Invalid link')}
{t('Click here to retry')}

); } else if (type === 'signup-paid' && status === 'error') { return (

- Signup error: Invalid link
Click here to retry + {t('Signup error: Invalid link')}
{t('Click here to retry')}

); } else if (type === 'stripe:checkout' && status === 'success') { if (context.member) { return (

- Success! Your account is fully activated, you now have access to all content. + {t('Success! Your account is fully activated, you now have access to all content.')}

); } return (

- Success! Check your email for magic link to sign-in. + {t('Success! Check your email for magic link to sign-in.')}

); } else if (type === 'stripe:checkout' && status === 'warning') { @@ -96,19 +100,19 @@ const NotificationText = ({type, status, context}) => { if (context.member) { return (

- Plan upgrade was cancelled. + {t('Plan upgrade was cancelled.')}

); } return (

- Plan checkout was cancelled. + {t('Plan checkout was cancelled.')}

); } return (

- {status === 'success' ? 'Success' : 'Error'} + {status === 'success' ? t('Success') : t('Error')}

); }; diff --git a/ghost/portal/src/components/common/NewsletterManagement.js b/ghost/portal/src/components/common/NewsletterManagement.js index b1245e9b31..f3380b9f67 100644 --- a/ghost/portal/src/components/common/NewsletterManagement.js +++ b/ghost/portal/src/components/common/NewsletterManagement.js @@ -207,6 +207,7 @@ export default function NewsletterManagement({ className="gh-portal-btn-text gh-email-faq-page-button" onClick={() => onAction('switchPage', {page: 'emailReceivingFAQ'})} > + {/* eslint-disable-next-line i18next/no-literal-string */} {t('Get help')} → diff --git a/ghost/portal/src/components/common/PopupNotification.js b/ghost/portal/src/components/common/PopupNotification.js index 957dff5b4c..ca4f72ef73 100644 --- a/ghost/portal/src/components/common/PopupNotification.js +++ b/ghost/portal/src/components/common/PopupNotification.js @@ -5,6 +5,8 @@ import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark-fill import {ReactComponent as WarningIcon} from '../../images/icons/warning-fill.svg'; import {getSupportAddress} from '../../utils/helpers'; import {clearURLParams} from '../../utils/notifications'; +import Interpolate from '@doist/react-interpolate'; +import {SYNTAX_I18NEXT} from '@doist/react-interpolate'; export const PopupNotificationStyles = ` .gh-portal-popupnotification { @@ -77,24 +79,24 @@ export const PopupNotificationStyles = ` } @keyframes popupnotification-slidein { - 0% { - transform: translateY(-10px); + 0% { + transform: translateY(-10px); opacity: 0; } 60% { transform: translateY(2px); } - 100% { - transform: translateY(0); + 100% { + transform: translateY(0); opacity: 1.0; } } @keyframes popupnotification-slideout { - 0% { + 0% { transform: translateY(0); opacity: 1.0; } 40% { transform: translateY(2px); } - 100% { + 100% { transform: translateY(-10px); opacity: 0; } @@ -110,7 +112,7 @@ const CloseButton = ({hide = false, onClose}) => { ); }; -const NotificationText = ({message, site}) => { +const NotificationText = ({message, site, t}) => { const supportAddress = getSupportAddress({site}); const supportAddressMail = `mailto:${supportAddress}`; if (message) { @@ -119,9 +121,18 @@ const NotificationText = ({message, site}) => { ); } return ( -

An unexpected error occured. Please try again or { - supportAddressMail && window.open(supportAddressMail); - }}>contact support if the error persists.

+

+ contact support if the error persists.')} + mapping={{ + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: { + supportAddressMail && window.open(supportAddressMail); + }}/> + }} + /> +

); }; @@ -187,7 +198,7 @@ export default class PopupNotification extends React.Component { } render() { - const {popupNotification, site} = this.context; + const {popupNotification, site, t} = this.context; const {className} = this.state; const {type, status, closeable, message} = popupNotification; const statusClass = status ? ` ${status}` : ''; @@ -196,9 +207,9 @@ export default class PopupNotification extends React.Component { return (
this.onAnimationEnd(e)}> {(status === 'error' ? : )} - + this.closeNotification(e)}/>
); } -} \ No newline at end of file +} diff --git a/ghost/portal/src/components/common/PoweredBy.js b/ghost/portal/src/components/common/PoweredBy.js index 1d4246bc67..80fa089f87 100644 --- a/ghost/portal/src/components/common/PoweredBy.js +++ b/ghost/portal/src/components/common/PoweredBy.js @@ -8,7 +8,7 @@ export default class PoweredBy extends React.Component { render() { // Note: please do not wrap "Powered by Ghost" in the translation function, as we don't // want it to be translated - + /* eslint-disable i18next/no-literal-string */ return (
{ window.open('https://ghost.org', '_blank'); @@ -17,5 +17,6 @@ export default class PoweredBy extends React.Component { Powered by Ghost ); + /* eslint-enable i18next/no-literal-string */ } } diff --git a/ghost/portal/src/components/common/ProductsSection.js b/ghost/portal/src/components/common/ProductsSection.js index b94d1fa68d..50ada26a76 100644 --- a/ghost/portal/src/components/common/ProductsSection.js +++ b/ghost/portal/src/components/common/ProductsSection.js @@ -814,7 +814,7 @@ function ProductCards({products, selectedInterval, handleChooseSignup, errors}) } function YearlyDiscount({discount, trialDays}) { - const {site} = useContext(AppContext); + const {site, t} = useContext(AppContext); const {portal_plans: portalPlans} = site; if (discount === 0 || !portalPlans.includes('monthly')) { @@ -824,13 +824,13 @@ function YearlyDiscount({discount, trialDays}) { if (hasFreeTrialTier({site})) { return ( <> - {discount}% discount + {t('{{discountPercent}} discount', {discountPercent: discount + '%'})} ); } else { return ( <> - {discount}% discount + {t('{{discountPercent}} discount', {discountPercent: discount + '%'})} ); } @@ -1034,7 +1034,7 @@ function ProductDescription({product, selectedPrice, activePrice}) { } function ChangeProductCard({product, onPlanSelect}) { - const {member, site} = useContext(AppContext); + const {member, site, t} = useContext(AppContext); const {selectedProduct, setSelectedProduct, selectedInterval} = useContext(ProductsContext); const cardClass = selectedProduct === product.id ? 'gh-portal-product-card checked' : 'gh-portal-product-card'; const monthlyPrice = product.monthlyPrice; @@ -1061,7 +1061,7 @@ function ChangeProductCard({product, onPlanSelect}) { {(currentPlan ?
- Current plan + {t('Current plan')}
:
@@ -1071,7 +1071,7 @@ function ChangeProductCard({product, onPlanSelect}) { onClick={() => { onPlanSelect(null, selectedPrice?.id); }} - >Choose + >{t('Choose')}
)} diff --git a/ghost/portal/src/components/common/SiteTitleBackButton.js b/ghost/portal/src/components/common/SiteTitleBackButton.js index b0aec53d0c..7f07f0f063 100644 --- a/ghost/portal/src/components/common/SiteTitleBackButton.js +++ b/ghost/portal/src/components/common/SiteTitleBackButton.js @@ -17,6 +17,7 @@ export default class SiteTitleBackButton extends React.Component { this.context.onAction('closePopup'); } }}> + {/* eslint-disable-next-line i18next/no-literal-string */} {t('Back')} diff --git a/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js b/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js index 9d9e1f099d..5303d7a174 100644 --- a/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js +++ b/ghost/portal/src/components/pages/AccountHomePage/components/EmailPreferencesAction.js @@ -17,7 +17,8 @@ function EmailPreferencesAction() { ? (

- You're currently not receiving emails + {t('You\'re not receiving emails')} + {t('You\'re currently not receiving emails')}

) :

{t('Update your preferences')}

diff --git a/ghost/portal/src/components/pages/OfferPage.js b/ghost/portal/src/components/pages/OfferPage.js index d84620906e..427f4697f5 100644 --- a/ghost/portal/src/components/pages/OfferPage.js +++ b/ghost/portal/src/components/pages/OfferPage.js @@ -431,7 +431,7 @@ export default class OfferPage extends React.Component { } renderOfferTag() { - const {pageData: offer} = this.context; + const {pageData: offer, t} = this.context; if (offer.amount <= 0) { return ( @@ -441,18 +441,20 @@ export default class OfferPage extends React.Component { if (offer.type === 'fixed') { return ( -
{getCurrencySymbol(offer.currency)}{offer.amount / 100} off
+
{t('{{amount}} off', { + amount: `${getCurrencySymbol(offer.currency)}${offer.amount / 100}` + })}
); } if (offer.type === 'trial') { return ( -
{offer.amount} days free
+
{t('{{amount}} days free', {amount: offer.amount})}
); } return ( -
{offer.amount}% off
+
{t('{{amount}} off', {amount: offer.amount + '%'})}
); } @@ -515,32 +517,51 @@ export default class OfferPage extends React.Component { return ''; } - renderOfferMessage({offer, product, price}) { - const discountDuration = offer.duration; - let durationLabel = ''; + renderOfferMessage({offer, product, price, t}) { + const offerMessages = { + forever: t(`{{amount}} off forever.`, { + amount: this.getOffAmount({offer}) + }), + firstPeriod: t(`{{amount}} off for first {{period}}.`, { + amount: this.getOffAmount({offer}), + period: offer.cadence + }), + firstNMonths: t(`{{amount}} off for first {{number}} months.`, { + amount: this.getOffAmount({offer}), + number: offer.duration_in_months || '' + }) + }; + const originalPrice = this.getOriginalPrice({offer, product}); - let renewsLabel = ''; + const renewsLabel = t(`Renews at {{price}}.`, {price: originalPrice}); + + let offerLabel = ''; + let useRenewsLabel = false; + const discountDuration = offer.duration; if (discountDuration === 'once') { - durationLabel = `for first ${offer.cadence}`; - renewsLabel = `Renews at ${originalPrice}.`; + offerLabel = offerMessages.firstPeriod; + useRenewsLabel = true; } else if (discountDuration === 'forever') { - durationLabel = `forever`; + offerLabel = offerMessages.forever; } else if (discountDuration === 'repeating') { const durationInMonths = offer.duration_in_months || ''; if (durationInMonths === 1) { - durationLabel = `for first month`; + offerLabel = offerMessages.firstPeriod; } else { - durationLabel = `for first ${durationInMonths} months`; + offerLabel = offerMessages.firstNMonths; } - renewsLabel = `Renews at ${originalPrice}.`; + useRenewsLabel = true; } if (discountDuration === 'trial') { return ( -

Try free for {offer.amount} days, then {originalPrice}. Cancel anytime.

+

{t('Try free for {{amount}} days, then {{originalPrice}}.', { + amount: offer.amount, + originalPrice: originalPrice + })} {t('Cancel anytime.')}

); } return ( -

{this.getOffAmount({offer})} off {durationLabel}. {renewsLabel}

+

{offerLabel} {useRenewsLabel ? renewsLabel : ''}

); } @@ -611,7 +632,7 @@ export default class OfferPage extends React.Component { {(benefits.length ? this.renderBenefits({product}) : '')} - +
{this.renderSignupTerms()} @@ -625,7 +646,7 @@ export default class OfferPage extends React.Component { } render() { - const {pageData: offer, site} = this.context; + const {pageData: offer, site, t} = this.context; if (!offer) { return null; } @@ -647,7 +668,7 @@ export default class OfferPage extends React.Component {
- {(offer.display_title ?

{offer.display_title}

:

Black Friday

)} + {(offer.display_title ?

{offer.display_title}

:

{t('Black Friday')}

)} {this.renderOfferTag()}
{(offer.display_description ?

{offer.display_description}

: '')} diff --git a/yarn.lock b/yarn.lock index 3fdf88ff57..6536535f08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2034,10 +2034,10 @@ resolved "https://registry.yarnpkg.com/@ebay/nice-modal-react/-/nice-modal-react-1.2.10.tgz#6b2406bfce4a5daffc43f5b85f5f238311cdfe93" integrity sha512-qNp8vQo5kPRwB9bHlkh8lcwH/0KFWpp58X/b9KaLB/gNlJ3W24nCT2l/qBBSnWgV7NEIq25uLowaPS2mbfpZiw== -"@doist/react-interpolate@0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@doist/react-interpolate/-/react-interpolate-0.4.0.tgz#b22a408ec78374213e0148473f082dd4c9a72285" - integrity sha512-hGSaTKnY5U6F3/MvuoZfGvtN4nbpL2NhL7c8atQqm0Yn4E2I4ChQQiMxm9/3NEOzd6VfyFxuK9Eo85ogTIO/PA== +"@doist/react-interpolate@0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@doist/react-interpolate/-/react-interpolate-0.4.1.tgz#696d14e90ffc849e94a4e3e94578c5eade1590b8" + integrity sha512-JxBJMpDlXByMrA7T6Z+tRdCqXlbnM1ikfasLaqMrWvV4TvvrPuD7dngU/X01iP9tpAycfxasNvbNKFs/QehViQ== "@elastic/elasticsearch@8.6.0": version "8.6.0" @@ -16801,6 +16801,14 @@ eslint-plugin-ghost@3.0.0: eslint-plugin-sort-imports-es6-autofix "0.6.0" eslint-plugin-unicorn "42.0.0" +eslint-plugin-i18next@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-i18next/-/eslint-plugin-i18next-6.0.1.tgz#b0a289179598b247a73881abccb4b73ea8a5e835" + integrity sha512-aJ0UlZLVqBK7mdXsl3yFQNz72OPUjSmBfcrMjCNxfMp8YVBAlKE83ZfzzTLCMQvFlXZs0TWYTtoteO/ZiZdDdw== + dependencies: + lodash "^4.17.21" + requireindex "~1.1.0" + eslint-plugin-import@^2.25.3: version "2.27.5" resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz#876a6d03f52608a3e5bb439c2550588e51dd6c65" @@ -29227,6 +29235,11 @@ requireindex@^1.2.0: resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef" integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww== +requireindex@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.1.0.tgz#e5404b81557ef75db6e49c5a72004893fe03e162" + integrity sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg== + requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"