mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-25 11:55:03 +03:00
Added translation wrapper to public-facing strings in Portal
refs https://github.com/TryGhost/Ghost/issues/15502 - in order to use the translations, strings must be wrapped in the `t` function, which is passed through AppContext - whilst I haven't instrumented all public strings, the vast majority are done here and the strings have been brought into the JSON locale files using `yarn translate:generate`
This commit is contained in:
parent
f007094d4b
commit
acf2ab2d22
@ -1,3 +1,62 @@
|
||||
{
|
||||
"Hello": "Hello"
|
||||
"{{discount}}% discount": "",
|
||||
"{{trialDays}} days free": "",
|
||||
"A login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "",
|
||||
"Account": "",
|
||||
"Account settings": "",
|
||||
"After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "",
|
||||
"Already a member?": "",
|
||||
"Back": "",
|
||||
"Back to Log in": "",
|
||||
"Cancel subscription": "",
|
||||
"Cancellation reason": "",
|
||||
"Choose a different plan": "",
|
||||
"Choose your newsletters": "",
|
||||
"Close": "",
|
||||
"Comments": "",
|
||||
"Confirm": "",
|
||||
"Continue": "",
|
||||
"Delete account": "",
|
||||
"Don't have an account?": "",
|
||||
"Email": "",
|
||||
"Email preference updated.": "",
|
||||
"Email preferences": "",
|
||||
"Emails": "",
|
||||
"Emails disabled": "",
|
||||
"Get help": "",
|
||||
"Get notified when someone replies to your comment": "",
|
||||
"Give feedback on this post": "",
|
||||
"Hello": "",
|
||||
"Less like this": "",
|
||||
"Manage": "",
|
||||
"Monthly": "",
|
||||
"More like this": "",
|
||||
"Name": "",
|
||||
"Not receiving emails?": "",
|
||||
"Now check your email!": "",
|
||||
"Powered by Ghost": "",
|
||||
"Price": "",
|
||||
"Re-enable emails": "",
|
||||
"Retry": "",
|
||||
"Save": "",
|
||||
"Sending login link...": "",
|
||||
"Sending...": "",
|
||||
"Sign in": "",
|
||||
"Sign up": "",
|
||||
"Start {{amount}}-day free trial": "",
|
||||
"Submit feedback": "",
|
||||
"Successfully unsubscribed": "",
|
||||
"Thanks for the feedback!": "",
|
||||
"That didn't go to plan": "",
|
||||
"This site is invite-only, contact the owner for access.": "",
|
||||
"To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "",
|
||||
"Unsubscribe from all emails": "",
|
||||
"Unsubscribing from emails will not cancel your paid subscription to {{title}}": "",
|
||||
"Update your preferences": "",
|
||||
"We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "",
|
||||
"Yearly": "",
|
||||
"You have been successfully resubscribed": "",
|
||||
"You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "",
|
||||
"Your account": "",
|
||||
"Your input helps shape what gets published.": ""
|
||||
}
|
||||
|
@ -1,3 +1,62 @@
|
||||
{
|
||||
"Hello": "Hallo"
|
||||
"{{discount}}% discount": "",
|
||||
"{{trialDays}} days free": "",
|
||||
"A login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "",
|
||||
"Account": "",
|
||||
"Account settings": "",
|
||||
"After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "",
|
||||
"Already a member?": "",
|
||||
"Back": "",
|
||||
"Back to Log in": "",
|
||||
"Cancel subscription": "",
|
||||
"Cancellation reason": "",
|
||||
"Choose a different plan": "",
|
||||
"Choose your newsletters": "",
|
||||
"Close": "",
|
||||
"Comments": "",
|
||||
"Confirm": "",
|
||||
"Continue": "",
|
||||
"Delete account": "",
|
||||
"Don't have an account?": "",
|
||||
"Email": "",
|
||||
"Email preference updated.": "",
|
||||
"Email preferences": "",
|
||||
"Emails": "",
|
||||
"Emails disabled": "",
|
||||
"Get help": "",
|
||||
"Get notified when someone replies to your comment": "",
|
||||
"Give feedback on this post": "",
|
||||
"Hello": "Hallo",
|
||||
"Less like this": "",
|
||||
"Manage": "",
|
||||
"Monthly": "",
|
||||
"More like this": "",
|
||||
"Name": "",
|
||||
"Not receiving emails?": "",
|
||||
"Now check your email!": "",
|
||||
"Powered by Ghost": "",
|
||||
"Price": "",
|
||||
"Re-enable emails": "",
|
||||
"Retry": "",
|
||||
"Save": "",
|
||||
"Sending login link...": "",
|
||||
"Sending...": "",
|
||||
"Sign in": "",
|
||||
"Sign up": "",
|
||||
"Start {{amount}}-day free trial": "",
|
||||
"Submit feedback": "",
|
||||
"Successfully unsubscribed": "",
|
||||
"Thanks for the feedback!": "",
|
||||
"That didn't go to plan": "",
|
||||
"This site is invite-only, contact the owner for access.": "",
|
||||
"To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "",
|
||||
"Unsubscribe from all emails": "",
|
||||
"Unsubscribing from emails will not cancel your paid subscription to {{title}}": "",
|
||||
"Update your preferences": "",
|
||||
"We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "",
|
||||
"Yearly": "",
|
||||
"You have been successfully resubscribed": "",
|
||||
"You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "",
|
||||
"Your account": "",
|
||||
"Your input helps shape what gets published.": ""
|
||||
}
|
||||
|
@ -10,13 +10,13 @@ import {ReactComponent as CheckmarkIcon} from '../../images/icons/check-circle.s
|
||||
const React = require('react');
|
||||
|
||||
function AccountHeader() {
|
||||
const {brandColor, lastPage, onAction} = useContext(AppContext);
|
||||
const {brandColor, lastPage, onAction, t} = useContext(AppContext);
|
||||
return (
|
||||
<header className='gh-portal-detail-header'>
|
||||
<BackButton brandColor={brandColor} hidden={!lastPage} onClick={(e) => {
|
||||
onAction('back');
|
||||
}} />
|
||||
<h3 className='gh-portal-main-title'>Email preferences</h3>
|
||||
<h3 className='gh-portal-main-title'>{t('Email preferences')}</h3>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -86,6 +86,7 @@ function NewsletterPrefSection({newsletter, subscribedNewsletters, setSubscribed
|
||||
}
|
||||
|
||||
function CommentsSection({updateCommentNotifications, isCommentsEnabled, enableCommentNotifications}) {
|
||||
const {t} = useContext(AppContext);
|
||||
const isChecked = !!enableCommentNotifications;
|
||||
|
||||
const [showUpdated, setShowUpdated] = useState(false);
|
||||
@ -98,8 +99,8 @@ function CommentsSection({updateCommentNotifications, isCommentsEnabled, enableC
|
||||
return (
|
||||
<section className='gh-portal-list-toggle-wrapper' data-test-toggle-wrapper>
|
||||
<div className='gh-portal-list-detail'>
|
||||
<h3>Comments</h3>
|
||||
<p>Get notified when someone replies to your comment</p>
|
||||
<h3>{t('Comments')}</h3>
|
||||
<p>{t('Get notified when someone replies to your comment')}</p>
|
||||
</div>
|
||||
<div style={{display: 'flex', alignItems: 'center'}}>
|
||||
<SuccessIcon show={showUpdated} checked={isChecked} />
|
||||
@ -133,9 +134,11 @@ function NewsletterPrefs({subscribedNewsletters, setSubscribedNewsletters}) {
|
||||
}
|
||||
|
||||
function ShowPaidMemberMessage({site, isPaid}) {
|
||||
const {t} = useContext(AppContext);
|
||||
|
||||
if (isPaid) {
|
||||
return (
|
||||
<p style={{textAlign: 'center', marginTop: '12px', marginBottom: '0', color: 'var(--grey6)'}}>Unsubscribing from emails will not cancel your paid subscription to {site?.title}</p>
|
||||
<p style={{textAlign: 'center', marginTop: '12px', marginBottom: '0', color: 'var(--grey6)'}}>{t('Unsubscribing from emails will not cancel your paid subscription to {{title}}', {title: site?.title})}</p>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@ -151,7 +154,7 @@ export default function NewsletterManagement({
|
||||
isCommentsEnabled,
|
||||
enableCommentNotifications
|
||||
}) {
|
||||
const {brandColor, onAction, member, site} = useContext(AppContext);
|
||||
const {brandColor, onAction, member, site, t} = useContext(AppContext);
|
||||
const isDisabled = !subscribedNewsletters?.length && ((isCommentsEnabled && !enableCommentNotifications) || !isCommentsEnabled);
|
||||
const EmptyNotification = () => {
|
||||
return null;
|
||||
@ -192,7 +195,7 @@ export default function NewsletterManagement({
|
||||
disabled={isDisabled}
|
||||
brandColor={brandColor}
|
||||
isPrimary={false}
|
||||
label='Unsubscribe from all emails'
|
||||
label={t('Unsubscribe from all emails')}
|
||||
isDestructive={true}
|
||||
style={{width: '100%'}}
|
||||
dataTestId="unsubscribe-from-all-emails"
|
||||
@ -201,12 +204,12 @@ export default function NewsletterManagement({
|
||||
</div>
|
||||
{hasMemberGotEmailSuppression({member}) && !isDisabled &&
|
||||
<div className="gh-portal-footer-secondary">
|
||||
<span className="gh-portal-footer-secondary-light">Not receiving emails?</span>
|
||||
<span className="gh-portal-footer-secondary-light">{t('Not receiving emails?')}</span>
|
||||
<button
|
||||
className="gh-portal-btn-text gh-email-faq-page-button"
|
||||
onClick={() => onAction('switchPage', {page: 'emailReceivingFAQ'})}
|
||||
>
|
||||
Get help →
|
||||
{t('Get help')} →
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
@ -1,15 +1,20 @@
|
||||
import React from 'react';
|
||||
import AppContext from '../../AppContext';
|
||||
import {ReactComponent as GhostLogo} from '../../images/ghost-logo-small.svg';
|
||||
|
||||
export default class PoweredBy extends React.Component {
|
||||
static contextType = AppContext;
|
||||
|
||||
render() {
|
||||
const {t} = this.context;
|
||||
|
||||
return (
|
||||
<a href='https://ghost.org' target='_blank' rel='noopener noreferrer' onClick={() => {
|
||||
window.open('https://ghost.org', '_blank');
|
||||
}}>
|
||||
<GhostLogo />
|
||||
Powered by Ghost
|
||||
{t('Powered by Ghost')}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -547,12 +547,12 @@ function ProductCardAlternatePrice({price}) {
|
||||
}
|
||||
|
||||
function ProductCardTrialDays({trialDays, discount, selectedInterval}) {
|
||||
const {site} = useContext(AppContext);
|
||||
const {site, t} = useContext(AppContext);
|
||||
|
||||
if (hasFreeTrialTier({site})) {
|
||||
if (trialDays) {
|
||||
return (
|
||||
<span className="gh-portal-discount-label">{trialDays} days free</span>
|
||||
<span className="gh-portal-discount-label">{t('{{trialDays}} days free', {trialDays})}</span>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
@ -561,7 +561,7 @@ function ProductCardTrialDays({trialDays, discount, selectedInterval}) {
|
||||
|
||||
if (selectedInterval === 'year') {
|
||||
return (
|
||||
<span className="gh-portal-discount-label">{discount}% discount</span>
|
||||
<span className="gh-portal-discount-label">{t('{{discount}}% discount', {discount})}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -809,7 +809,7 @@ function YearlyDiscount({discount, trialDays}) {
|
||||
}
|
||||
|
||||
function ProductPriceSwitch({products, selectedInterval, setSelectedInterval}) {
|
||||
const {site} = useContext(AppContext);
|
||||
const {site, t} = useContext(AppContext);
|
||||
const {portal_plans: portalPlans} = site;
|
||||
if (!portalPlans.includes('monthly') || !portalPlans.includes('yearly')) {
|
||||
return null;
|
||||
@ -825,7 +825,7 @@ function ProductPriceSwitch({products, selectedInterval, setSelectedInterval}) {
|
||||
setSelectedInterval('month');
|
||||
}}
|
||||
>
|
||||
Monthly
|
||||
{t('Monthly')}
|
||||
</button>
|
||||
<button
|
||||
data-test-button='switch-yearly'
|
||||
@ -834,7 +834,7 @@ function ProductPriceSwitch({products, selectedInterval, setSelectedInterval}) {
|
||||
setSelectedInterval('year');
|
||||
}}
|
||||
>
|
||||
Yearly
|
||||
{t('Yearly')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@ export default class SiteTitleBackButton extends React.Component {
|
||||
static contextType = AppContext;
|
||||
|
||||
render() {
|
||||
// const {site} = this.context;
|
||||
const {t} = this.context;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@ -17,7 +17,7 @@ export default class SiteTitleBackButton extends React.Component {
|
||||
this.context.onAction('closePopup');
|
||||
}
|
||||
}}>
|
||||
<span>← </span> Back
|
||||
<span>← </span> {t('Back')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
@ -6,7 +6,7 @@ import NewsletterManagement from '../common/NewsletterManagement';
|
||||
const React = require('react');
|
||||
|
||||
export default function AccountEmailPage() {
|
||||
const {member, onAction, site} = useContext(AppContext);
|
||||
const {member, onAction, site, t} = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!member) {
|
||||
@ -40,7 +40,7 @@ export default function AccountEmailPage() {
|
||||
setSubscribedNewsletters([]);
|
||||
onAction('showPopupNotification', {
|
||||
action: 'updated:success',
|
||||
message: `Email preference updated.`
|
||||
message: t(`Email preference updated.`)
|
||||
});
|
||||
const data = {newsletters: []};
|
||||
if (commentsEnabled) {
|
||||
|
@ -4,14 +4,14 @@ import {isEmailSuppressed} from 'utils/helpers';
|
||||
import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg';
|
||||
|
||||
function EmailPreferencesAction() {
|
||||
const {onAction, member} = useContext(AppContext);
|
||||
const {onAction, member, t} = useContext(AppContext);
|
||||
const emailSuppressed = isEmailSuppressed({member});
|
||||
const page = emailSuppressed ? 'emailSuppressed' : 'accountEmail';
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className='gh-portal-list-detail'>
|
||||
<h3>Emails</h3>
|
||||
<h3>{t('Emails')}</h3>
|
||||
{
|
||||
emailSuppressed
|
||||
? (
|
||||
@ -20,7 +20,7 @@ function EmailPreferencesAction() {
|
||||
<span>You're <span className="gh-mobile-shortener">currently </span>not receiving emails</span>
|
||||
</p>
|
||||
)
|
||||
: <p>Update your preferences</p>
|
||||
: <p>{t('Update your preferences')}</p>
|
||||
}
|
||||
</div>
|
||||
<button className='gh-portal-btn gh-portal-btn-list' onClick={(e) => {
|
||||
@ -29,7 +29,7 @@ function EmailPreferencesAction() {
|
||||
lastPage: 'accountHome'
|
||||
});
|
||||
}} data-test-button='manage-newsletters'>
|
||||
Manage
|
||||
{t('Manage')}
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
|
@ -3,12 +3,12 @@ import MemberAvatar from 'components/common/MemberGravatar';
|
||||
import React, {useContext} from 'react';
|
||||
|
||||
const UserHeader = () => {
|
||||
const {member, brandColor} = useContext(AppContext);
|
||||
const {member, brandColor, t} = useContext(AppContext);
|
||||
const avatar = member.avatar_image;
|
||||
return (
|
||||
<header className='gh-portal-account-header'>
|
||||
<MemberAvatar gravatar={avatar} style={{userIcon: {color: brandColor, width: '56px', height: '56px', padding: '2px'}}} />
|
||||
<h2 className="gh-portal-main-title">Your account</h2>
|
||||
<h2 className="gh-portal-main-title">{t('Your account')}</h2>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
@ -64,7 +64,7 @@ const Header = ({onBack, showConfirmation, confirmationType}) => {
|
||||
};
|
||||
|
||||
const CancelSubscriptionButton = ({member, onCancelSubscription, action, brandColor}) => {
|
||||
const {site} = useContext(AppContext);
|
||||
const {site, t} = useContext(AppContext);
|
||||
if (!member.paid) {
|
||||
return null;
|
||||
}
|
||||
@ -77,7 +77,7 @@ const CancelSubscriptionButton = ({member, onCancelSubscription, action, brandCo
|
||||
if (subscription.cancel_at_period_end) {
|
||||
return null;
|
||||
}
|
||||
const label = 'Cancel subscription';
|
||||
const label = t('Cancel subscription');
|
||||
const isRunning = ['cancelSubscription:running'].includes(action);
|
||||
const disabled = (isRunning) ? true : false;
|
||||
const isPrimary = !!subscription.cancel_at_period_end;
|
||||
@ -110,11 +110,11 @@ const CancelSubscriptionButton = ({member, onCancelSubscription, action, brandCo
|
||||
|
||||
// For confirmation flows
|
||||
const PlanConfirmationSection = ({plan, type, onConfirm}) => {
|
||||
const {site, action, member, brandColor} = useContext(AppContext);
|
||||
const {site, action, member, brandColor, t} = useContext(AppContext);
|
||||
const [reason, setReason] = useState('');
|
||||
const subscription = getMemberSubscription({member});
|
||||
const isRunning = ['updateSubscription:running', 'checkoutPlan:running', 'cancelSubscription:running'].includes(action);
|
||||
const label = 'Confirm';
|
||||
const label = t('Confirm');
|
||||
let planStartDate = getDateString(subscription.current_period_end);
|
||||
const currentActivePlan = getMemberActivePrice({member});
|
||||
if (currentActivePlan.id !== plan.id) {
|
||||
@ -123,14 +123,14 @@ const PlanConfirmationSection = ({plan, type, onConfirm}) => {
|
||||
const priceString = formatNumber(plan.price);
|
||||
const planStartMessage = `${plan.currency_symbol}${priceString}/${plan.interval} – Starting ${planStartDate}`;
|
||||
const product = getProductFromPrice({site, priceId: plan?.id});
|
||||
const priceLabel = hasMultipleProductsFeature({site}) ? product?.name : 'Price';
|
||||
const priceLabel = hasMultipleProductsFeature({site}) ? product?.name : t('Price');
|
||||
if (type === 'changePlan') {
|
||||
return (
|
||||
<div className='gh-portal-logged-out-form-container'>
|
||||
<div className='gh-portal-list mb6'>
|
||||
<section>
|
||||
<div className='gh-portal-list-detail'>
|
||||
<h3>Account</h3>
|
||||
<h3>{t('Account')}</h3>
|
||||
<p>{member.email}</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -161,7 +161,7 @@ const PlanConfirmationSection = ({plan, type, onConfirm}) => {
|
||||
<p>If you cancel your subscription now, you will continue to have access until <strong>{getDateString(subscription.current_period_end)}</strong>.</p>
|
||||
<section className='gh-portal-input-section'>
|
||||
<div className='gh-portal-input-labelcontainer'>
|
||||
<label className='gh-portal-input-label'>Cancellation reason</label>
|
||||
<label className='gh-portal-input-label'>{t('Cancellation reason')}</label>
|
||||
</div>
|
||||
<textarea
|
||||
data-test-input='cancellation-reason'
|
||||
|
@ -55,10 +55,12 @@ export default class AccountProfilePage extends React.Component {
|
||||
}
|
||||
|
||||
renderSaveButton() {
|
||||
const {t} = this.context;
|
||||
|
||||
const isRunning = (this.context.action === 'updateProfile:running');
|
||||
let label = 'Save';
|
||||
let label = t('Save');
|
||||
if (this.context.action === 'updateProfile:failed') {
|
||||
label = 'Retry';
|
||||
label = t('Retry');
|
||||
}
|
||||
const disabled = isRunning ? true : false;
|
||||
return (
|
||||
@ -75,8 +77,10 @@ export default class AccountProfilePage extends React.Component {
|
||||
}
|
||||
|
||||
renderDeleteAccountButton() {
|
||||
const {t} = this.context;
|
||||
|
||||
return (
|
||||
<div style={{cursor: 'pointer', color: 'red'}} role='button'>Delete account</div>
|
||||
<div style={{cursor: 'pointer', color: 'red'}} role='button'>{t('Delete account')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -89,10 +93,12 @@ export default class AccountProfilePage extends React.Component {
|
||||
}
|
||||
|
||||
renderHeader() {
|
||||
const {t} = this.context;
|
||||
|
||||
return (
|
||||
<header className='gh-portal-detail-header'>
|
||||
<BackButton brandColor={this.context.brandColor} hidden={!this.context.lastPage} onClick={e => this.onBack(e)} />
|
||||
<h3 className='gh-portal-main-title'>Account settings</h3>
|
||||
<h3 className='gh-portal-main-title'>{t('Account settings')}</h3>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -129,13 +135,15 @@ export default class AccountProfilePage extends React.Component {
|
||||
}
|
||||
|
||||
getInputFields({state, fieldNames}) {
|
||||
const {t} = this.context;
|
||||
|
||||
const errors = state.errors || {};
|
||||
const fields = [
|
||||
{
|
||||
type: 'text',
|
||||
value: state.name,
|
||||
placeholder: 'Jamie Larson',
|
||||
label: 'Name',
|
||||
label: t('Name'),
|
||||
name: 'name',
|
||||
required: true,
|
||||
errorMessage: errors.name || ''
|
||||
@ -144,7 +152,7 @@ export default class AccountProfilePage extends React.Component {
|
||||
type: 'email',
|
||||
value: state.email,
|
||||
placeholder: 'jamie@example.com',
|
||||
label: 'Email',
|
||||
label: t('Email'),
|
||||
name: 'email',
|
||||
required: true,
|
||||
errorMessage: errors.email || ''
|
||||
|
@ -7,7 +7,7 @@ import ActionButton from 'components/common/ActionButton';
|
||||
import {ReactComponent as EmailDeliveryFailedIcon} from 'images/icons/email-delivery-failed.svg';
|
||||
|
||||
export default function EmailSuppressedPage() {
|
||||
const {brandColor, lastPage, onAction, action, site} = useContext(AppContext);
|
||||
const {brandColor, lastPage, onAction, action, site, t} = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (['removeEmailFromSuppressionList:success'].includes(action)) {
|
||||
@ -26,13 +26,13 @@ export default function EmailSuppressedPage() {
|
||||
lastPage: 'accountHome'
|
||||
});
|
||||
onAction('showPopupNotification', {
|
||||
message: 'You have been successfully resubscribed'
|
||||
message: t('You have been successfully resubscribed')
|
||||
});
|
||||
} else {
|
||||
onAction('back');
|
||||
}
|
||||
}
|
||||
}, [action, onAction, site]);
|
||||
}, [action, onAction, site, t]);
|
||||
|
||||
const isRunning = ['removeEmailFromSuppressionList:running', 'refreshMemberData:running'].includes(action);
|
||||
|
||||
@ -52,9 +52,9 @@ export default function EmailSuppressedPage() {
|
||||
<EmailDeliveryFailedIcon className="gh-email-suppressed-page-icon" />
|
||||
|
||||
<div className="gh-email-suppressed-page-text">
|
||||
<h3 className="gh-portal-main-title gh-email-suppressed-page-title">Emails disabled</h3>
|
||||
<h3 className="gh-portal-main-title gh-email-suppressed-page-title">{t('Emails disabled')}</h3>
|
||||
<p>
|
||||
You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.
|
||||
{t('You\'re not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -64,7 +64,7 @@ export default function EmailSuppressedPage() {
|
||||
onClick={handleSubmit}
|
||||
disabled={isRunning}
|
||||
brandColor={brandColor}
|
||||
label="Re-enable emails"
|
||||
label={t('Re-enable emails')}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
</div>
|
||||
|
@ -153,14 +153,14 @@ export const FeedbackPageStyles = `
|
||||
}
|
||||
|
||||
@keyframes mobile-tray-from-bottom {
|
||||
0% {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(300px);
|
||||
}
|
||||
20% {
|
||||
opacity: 1.0;
|
||||
}
|
||||
100% {
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@ -196,7 +196,7 @@ function ErrorPage({error}) {
|
||||
}
|
||||
|
||||
const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
||||
const {onAction, brandColor} = useContext(AppContext);
|
||||
const {onAction, brandColor, t} = useContext(AppContext);
|
||||
const [score, setScore] = useState(initialScore);
|
||||
|
||||
const stopPropagation = (event) => {
|
||||
@ -223,7 +223,7 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
||||
|
||||
return (
|
||||
<div className="gh-portal-confirm-dialog" onMouseDown={stopPropagation}>
|
||||
<h1 className="gh-portal-confirm-title">Give feedback on this post</h1>
|
||||
<h1 className="gh-portal-confirm-title">{t('Give feedback on this post')}</h1>
|
||||
|
||||
<div className="gh-feedback-buttons-group">
|
||||
<button
|
||||
@ -232,7 +232,7 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
||||
onClick={() => setScore(1)}
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
More like this
|
||||
{t('More like this')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@ -241,7 +241,7 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
||||
onClick={() => setScore(0)}
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
Less like this
|
||||
{t('Less like this')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -251,7 +251,7 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
|
||||
onClick={submit}
|
||||
disabled={false}
|
||||
brandColor={brandColor}
|
||||
label="Submit feedback"
|
||||
label={t('Submit feedback')}
|
||||
isRunning={loading}
|
||||
tabindex="3"
|
||||
/>
|
||||
@ -274,7 +274,7 @@ const LoadingFeedbackView = ({action, score}) => {
|
||||
};
|
||||
|
||||
const ConfirmFeedback = ({positive}) => {
|
||||
const {onAction, brandColor} = useContext(AppContext);
|
||||
const {onAction, brandColor, t} = useContext(AppContext);
|
||||
|
||||
const icon = positive ? <ThumbUpIcon /> : <ThumbDownIcon />;
|
||||
|
||||
@ -285,15 +285,15 @@ const ConfirmFeedback = ({positive}) => {
|
||||
<div className="gh-feedback-icon">
|
||||
{icon}
|
||||
</div>
|
||||
<h1 className="gh-portal-main-title">Thanks for the feedback!</h1>
|
||||
<p className="gh-portal-text-center">Your input helps shape what gets published.</p>
|
||||
<h1 className="gh-portal-main-title">{t('Thanks for the feedback!')}</h1>
|
||||
<p className="gh-portal-text-center">{t('Your input helps shape what gets published.')}</p>
|
||||
<ActionButton
|
||||
style={{width: '100%'}}
|
||||
retry={false}
|
||||
onClick = {() => onAction('closePopup')}
|
||||
disabled={false}
|
||||
brandColor={brandColor}
|
||||
label={'Close'}
|
||||
label={t('Close')}
|
||||
isRunning={false}
|
||||
tabindex='3'
|
||||
classes={'sticky bottom'}
|
||||
|
@ -27,12 +27,14 @@ export default class MagicLinkPage extends React.Component {
|
||||
static contextType = AppContext;
|
||||
|
||||
renderFormHeader() {
|
||||
let popupTitle = `Now check your email!`;
|
||||
let popupDescription = `A login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.`;
|
||||
const {t} = this.context;
|
||||
|
||||
let popupTitle = t(`Now check your email!`);
|
||||
let popupDescription = t(`A login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.`);
|
||||
|
||||
if (this.context.lastPage === 'signup') {
|
||||
popupTitle = `Now check your email!`;
|
||||
popupDescription = `To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!`;
|
||||
popupTitle = t(`Now check your email!`);
|
||||
popupDescription = t(`To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!`);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -47,13 +49,15 @@ export default class MagicLinkPage extends React.Component {
|
||||
}
|
||||
|
||||
renderLoginMessage() {
|
||||
const {t} = this.context;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{color: '#1d1d1d', fontWeight: 'bold', cursor: 'pointer'}}
|
||||
onClick={() => this.context.onAction('switchPage', {page: 'signin'})}
|
||||
>
|
||||
Back to Log in
|
||||
{t('Back to Log in')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -64,7 +68,9 @@ export default class MagicLinkPage extends React.Component {
|
||||
}
|
||||
|
||||
renderCloseButton() {
|
||||
const label = 'Close';
|
||||
const {t} = this.context;
|
||||
|
||||
const label = t('Close');
|
||||
return (
|
||||
<ActionButton
|
||||
style={{width: '100%'}}
|
||||
|
@ -65,7 +65,7 @@ function NewsletterPrefs({subscribedNewsletters, setSubscribedNewsletters}) {
|
||||
}
|
||||
|
||||
export default function NewsletterSelectionPage({pageData, onBack}) {
|
||||
const {brandColor, site, onAction, action} = useContext(AppContext);
|
||||
const {brandColor, site, onAction, action, t} = useContext(AppContext);
|
||||
const siteNewsletters = getSiteNewsletters({site});
|
||||
const defaultNewsletters = siteNewsletters.filter((d) => {
|
||||
return d.subscribe_on_signup;
|
||||
@ -76,10 +76,10 @@ export default function NewsletterSelectionPage({pageData, onBack}) {
|
||||
if (action === 'signup:running') {
|
||||
isRunning = true;
|
||||
}
|
||||
let label = 'Continue';
|
||||
let label = t('Continue');
|
||||
let retry = false;
|
||||
if (action === 'signup:failed') {
|
||||
label = 'Retry';
|
||||
label = t('Retry');
|
||||
retry = true;
|
||||
}
|
||||
|
||||
@ -88,7 +88,7 @@ export default function NewsletterSelectionPage({pageData, onBack}) {
|
||||
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultNewsletters);
|
||||
return (
|
||||
<div className='gh-portal-content with-footer gh-portal-newsletter-selection'>
|
||||
<p className="gh-portal-text-center gh-portal-text-large">Choose your newsletters</p>
|
||||
<p className="gh-portal-text-center gh-portal-text-large">{t('Choose your newsletters')}</p>
|
||||
<div className='gh-portal-section'>
|
||||
<div className='gh-portal-list'>
|
||||
<NewsletterPrefs
|
||||
@ -124,7 +124,7 @@ export default function NewsletterSelectionPage({pageData, onBack}) {
|
||||
onClick = {() => {
|
||||
onBack();
|
||||
}}>
|
||||
<span>Choose a different plan</span>
|
||||
<span>{t('Choose a different plan')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -152,14 +152,14 @@ export default class OfferPage extends React.Component {
|
||||
|
||||
getInputFields({state, fieldNames}) {
|
||||
const {portal_name: portalName} = this.context.site;
|
||||
const {member} = this.context;
|
||||
const {member, t} = this.context;
|
||||
const errors = state.errors || {};
|
||||
const fields = [
|
||||
{
|
||||
type: 'email',
|
||||
value: member?.email || state.email,
|
||||
placeholder: 'jamie@example.com',
|
||||
label: 'Email',
|
||||
label: t('Email'),
|
||||
name: 'email',
|
||||
disabled: !!member,
|
||||
required: true,
|
||||
@ -181,7 +181,7 @@ export default class OfferPage extends React.Component {
|
||||
type: 'text',
|
||||
value: member?.name || state.name,
|
||||
placeholder: 'Jamie Larson',
|
||||
label: 'Name',
|
||||
label: t('Name'),
|
||||
name: 'name',
|
||||
disabled: !!member,
|
||||
required: true,
|
||||
@ -307,22 +307,22 @@ export default class OfferPage extends React.Component {
|
||||
}
|
||||
|
||||
renderSubmitButton() {
|
||||
const {action, brandColor} = this.context;
|
||||
const {action, brandColor, t} = this.context;
|
||||
const {pageData: offer} = this.context;
|
||||
let label = 'Continue';
|
||||
let label = t('Continue');
|
||||
|
||||
if (offer.type === 'trial') {
|
||||
label = 'Start ' + offer.amount + '-day free trial';
|
||||
label = t('Start {{amount}}-day free trial', {amount: offer.amount});
|
||||
}
|
||||
|
||||
let isRunning = false;
|
||||
if (action === 'signup:running') {
|
||||
label = 'Sending...';
|
||||
label = t('Sending...');
|
||||
isRunning = true;
|
||||
}
|
||||
let retry = false;
|
||||
if (action === 'signup:failed') {
|
||||
label = 'Retry';
|
||||
label = t('Retry');
|
||||
retry = true;
|
||||
}
|
||||
|
||||
@ -347,16 +347,16 @@ export default class OfferPage extends React.Component {
|
||||
if (member) {
|
||||
return null;
|
||||
}
|
||||
const {brandColor, onAction} = this.context;
|
||||
const {brandColor, onAction, t} = this.context;
|
||||
return (
|
||||
<div className='gh-portal-signup-message'>
|
||||
<div>Already a member?</div>
|
||||
<div>{t('Already a member?')}</div>
|
||||
<button
|
||||
className='gh-portal-btn gh-portal-btn-link'
|
||||
style={{color: brandColor}}
|
||||
onClick={() => onAction('switchPage', {page: 'signin'})}
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span>{t('Sign in')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -477,15 +477,15 @@ export default class OfferPage extends React.Component {
|
||||
}
|
||||
|
||||
renderProductLabel({product, offer}) {
|
||||
const {site} = this.context;
|
||||
const {site, t} = this.context;
|
||||
|
||||
if (hasMultipleProductsFeature({site})) {
|
||||
return (
|
||||
<h4 className="gh-portal-plan-name">{product.name} - {(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
|
||||
<h4 className="gh-portal-plan-name">{product.name} - {(offer.cadence === 'month' ? t('Monthly') : t('Yearly'))}</h4>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<h4 className="gh-portal-plan-name">{(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
|
||||
<h4 className="gh-portal-plan-name">{(offer.cadence === 'month' ? t('Monthly') : t('Yearly'))}</h4>
|
||||
);
|
||||
}
|
||||
|
||||
@ -520,6 +520,8 @@ export default class OfferPage extends React.Component {
|
||||
}
|
||||
|
||||
renderProductCard({product, offer, currencyClass, updatedPrice, price, benefits}) {
|
||||
const {t} = this.context;
|
||||
|
||||
if (this.state.showNewsletterSelection) {
|
||||
return null;
|
||||
}
|
||||
@ -527,7 +529,7 @@ export default class OfferPage extends React.Component {
|
||||
<>
|
||||
<div className='gh-portal-product-card top'>
|
||||
<div className='gh-portal-product-card-header'>
|
||||
<h4 className="gh-portal-product-name">{product.name} - {(offer.cadence === 'month' ? 'Monthly' : 'Yearly')}</h4>
|
||||
<h4 className="gh-portal-product-name">{product.name} - {(offer.cadence === 'month' ? t('Monthly') : t('Yearly'))}</h4>
|
||||
{this.renderOldTierPrice({offer, price})}
|
||||
{this.renderUpdatedTierPrice({offer, currencyClass, updatedPrice, price})}
|
||||
{this.renderOfferMessage({offer, product, price})}
|
||||
|
@ -56,13 +56,15 @@ export default class SigninPage extends React.Component {
|
||||
}
|
||||
|
||||
getInputFields({state}) {
|
||||
const {t} = this.context;
|
||||
|
||||
const errors = state.errors || {};
|
||||
const fields = [
|
||||
{
|
||||
type: 'email',
|
||||
value: state.email,
|
||||
placeholder: 'jamie@example.com',
|
||||
label: 'Email',
|
||||
label: t('Email'),
|
||||
name: 'email',
|
||||
required: true,
|
||||
errorMessage: errors.email || '',
|
||||
@ -73,13 +75,13 @@ export default class SigninPage extends React.Component {
|
||||
}
|
||||
|
||||
renderSubmitButton() {
|
||||
const {action} = this.context;
|
||||
const {action, t} = this.context;
|
||||
let retry = false;
|
||||
const isRunning = (action === 'signin:running');
|
||||
let label = isRunning ? 'Sending login link...' : 'Continue';
|
||||
let label = isRunning ? t('Sending login link...') : t('Continue');
|
||||
const disabled = isRunning ? true : false;
|
||||
if (action === 'signin:failed') {
|
||||
label = 'Retry';
|
||||
label = t('Retry');
|
||||
retry = true;
|
||||
}
|
||||
return (
|
||||
@ -97,17 +99,17 @@ export default class SigninPage extends React.Component {
|
||||
}
|
||||
|
||||
renderSignupMessage() {
|
||||
const brandColor = this.context.brandColor;
|
||||
const {brandColor, t} = this.context;
|
||||
return (
|
||||
<div className='gh-portal-signup-message'>
|
||||
<div>Don't have an account?</div>
|
||||
<div>{t('Don\'t have an account?')}</div>
|
||||
<button
|
||||
data-test-button='signup-switch'
|
||||
className='gh-portal-btn gh-portal-btn-link'
|
||||
style={{color: brandColor}}
|
||||
onClick={() => this.context.onAction('switchPage', {page: 'signup'})}
|
||||
>
|
||||
<span>Sign up</span>
|
||||
<span>{t('Sign up')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@ -143,11 +145,12 @@ export default class SigninPage extends React.Component {
|
||||
|
||||
renderFormHeader() {
|
||||
// const siteTitle = this.context.site.title || 'Site Title';
|
||||
const {t} = this.context;
|
||||
|
||||
return (
|
||||
<header className='gh-portal-signin-header'>
|
||||
{this.renderSiteLogo()}
|
||||
<h1 className="gh-portal-main-title">Sign in</h1>
|
||||
<h1 className="gh-portal-main-title">{t('Sign in')}</h1>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
@ -361,7 +361,7 @@ class SignupPage extends React.Component {
|
||||
}
|
||||
|
||||
getInputFields({state, fieldNames}) {
|
||||
const {portal_name: portalName} = this.context.site;
|
||||
const {site: {portal_name: portalName}, t} = this.context;
|
||||
|
||||
const errors = state.errors || {};
|
||||
const fields = [
|
||||
@ -369,7 +369,7 @@ class SignupPage extends React.Component {
|
||||
type: 'email',
|
||||
value: state.email,
|
||||
placeholder: 'jamie@example.com',
|
||||
label: 'Email',
|
||||
label: t('Email'),
|
||||
name: 'email',
|
||||
required: true,
|
||||
tabindex: 2,
|
||||
@ -383,7 +383,7 @@ class SignupPage extends React.Component {
|
||||
type: 'text',
|
||||
value: state.name,
|
||||
placeholder: 'Jamie Larson',
|
||||
label: 'Name',
|
||||
label: t('Name'),
|
||||
name: 'name',
|
||||
required: true,
|
||||
tabindex: 1,
|
||||
@ -400,29 +400,29 @@ class SignupPage extends React.Component {
|
||||
}
|
||||
|
||||
renderSubmitButton() {
|
||||
const {action, site, brandColor, pageQuery} = this.context;
|
||||
const {action, site, brandColor, pageQuery, t} = this.context;
|
||||
|
||||
if (isInviteOnlySite({site, pageQuery})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let label = 'Continue';
|
||||
let label = t('Continue');
|
||||
const showOnlyFree = pageQuery === 'free' && hasFreeProductPrice({site});
|
||||
|
||||
if (hasOnlyFreePlan({site}) || showOnlyFree) {
|
||||
label = 'Sign up';
|
||||
label = t('Sign up');
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isRunning = false;
|
||||
if (action === 'signup:running') {
|
||||
label = 'Sending...';
|
||||
label = t('Sending...');
|
||||
isRunning = true;
|
||||
}
|
||||
let retry = false;
|
||||
if (action === 'signup:failed') {
|
||||
label = 'Retry';
|
||||
label = t('Retry');
|
||||
retry = true;
|
||||
}
|
||||
|
||||
@ -456,11 +456,11 @@ class SignupPage extends React.Component {
|
||||
}
|
||||
|
||||
renderFreeTrialMessage() {
|
||||
const {site} = this.context;
|
||||
const {site, t} = this.context;
|
||||
if (hasFreeTrialTier({site}) && !isInviteOnlySite({site})) {
|
||||
return (
|
||||
<p className='gh-portal-free-trial-notification' data-testid="free-trial-notification-text">
|
||||
After a free trial ends, you will be charged the regular price for the tier you’ve chosen. You can always cancel before then.
|
||||
{t('After a free trial ends, you will be charged the regular price for the tier you\'ve chosen. You can always cancel before then.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@ -468,19 +468,19 @@ class SignupPage extends React.Component {
|
||||
}
|
||||
|
||||
renderLoginMessage() {
|
||||
const {brandColor, onAction} = this.context;
|
||||
const {brandColor, onAction, t} = this.context;
|
||||
return (
|
||||
<div>
|
||||
{this.renderFreeTrialMessage()}
|
||||
<div className='gh-portal-signup-message'>
|
||||
<div>Already a member?</div>
|
||||
<div>{t('Already a member?')}</div>
|
||||
<button
|
||||
data-test-button='signin-switch'
|
||||
className='gh-portal-btn gh-portal-btn-link'
|
||||
style={{color: brandColor}}
|
||||
onClick={() => onAction('switchPage', {page: 'signin'})}
|
||||
>
|
||||
<span>Sign in</span>
|
||||
<span>{t('Sign in')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -489,7 +489,7 @@ class SignupPage extends React.Component {
|
||||
|
||||
renderForm() {
|
||||
const fields = this.getInputFields({state: this.state});
|
||||
const {site, pageQuery} = this.context;
|
||||
const {site, pageQuery, t} = this.context;
|
||||
|
||||
if (this.state.showNewsletterSelection) {
|
||||
return (
|
||||
@ -512,7 +512,7 @@ class SignupPage extends React.Component {
|
||||
className='gh-portal-invite-only-notification'
|
||||
data-testid="invite-only-notification-text"
|
||||
>
|
||||
This site is invite-only, contact the owner for access.
|
||||
{t('This site is invite-only, contact the owner for access.')}
|
||||
</p>
|
||||
{this.renderLoginMessage()}
|
||||
</div>
|
||||
|
@ -41,7 +41,7 @@ async function updateMemberNewsletters({api, memberUuid, newsletters, enableComm
|
||||
}
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
const {site, pageData, onAction} = useContext(AppContext);
|
||||
const {site, pageData, onAction, t} = useContext(AppContext);
|
||||
const api = setupGhostApi({siteUrl: site.url});
|
||||
const [member, setMember] = useState();
|
||||
const siteNewsletters = getSiteNewsletters({site});
|
||||
@ -101,9 +101,9 @@ export default function UnsubscribePage() {
|
||||
<div class="gh-feedback-icon gh-feedback-icon-error">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<h1 className="gh-portal-main-title">That didn't go to plan</h1>
|
||||
<h1 className="gh-portal-main-title">{t('That didn\'t go to plan')}</h1>
|
||||
<div>
|
||||
<p className="gh-portal-text-center">We couldn't unsubscribe you as the email address was not found. Please contact the site owner.</p>
|
||||
<p className="gh-portal-text-center">{t('We couldn\'t unsubscribe you as the email address was not found. Please contact the site owner.')}</p>
|
||||
</div>
|
||||
<ActionButton
|
||||
style={{width: '100%'}}
|
||||
@ -111,7 +111,7 @@ export default function UnsubscribePage() {
|
||||
onClick = {() => onAction('closePopup')}
|
||||
disabled={false}
|
||||
brandColor='#000000'
|
||||
label={'Close'}
|
||||
label={t('Close')}
|
||||
isRunning={false}
|
||||
tabindex='3'
|
||||
classes={'sticky bottom'}
|
||||
@ -126,7 +126,7 @@ export default function UnsubscribePage() {
|
||||
<div className='gh-portal-content gh-portal-unsubscribe with-footer'>
|
||||
<CloseButton />
|
||||
<AccountHeader />
|
||||
<h1 className="gh-portal-main-title">Successfully unsubscribed</h1>
|
||||
<h1 className="gh-portal-main-title">{t('Successfully unsubscribed')}</h1>
|
||||
<div>
|
||||
<p className='gh-portal-text-center'><strong>{member?.email}</strong> will no longer receive this newsletter.</p>
|
||||
<p className='gh-portal-text-center'>Didn't mean to do this? Manage your preferences
|
||||
@ -182,7 +182,7 @@ export default function UnsubscribePage() {
|
||||
setSubscribedNewsletters([]);
|
||||
onAction('showPopupNotification', {
|
||||
action: 'updated:success',
|
||||
message: `Email preference updated.`
|
||||
message: t(`Email preference updated.`)
|
||||
});
|
||||
const updatedMember = await api.member.updateNewsletters({uuid: pageData.uuid, newsletters: [], enableCommentNotifications: false});
|
||||
setMember(updatedMember);
|
||||
|
Loading…
Reference in New Issue
Block a user