Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(l10n): fix date localization in sub management #15459

Merged
merged 1 commit into from
Jun 20, 2023
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 @@ -260,7 +260,7 @@ describe('CancelSubscriptionPanel', () => {
</LocalizationProvider>
);
expect(queryByText('$20.00 fooly')).toBeInTheDocument();
expect(queryByText('quuz 09/13/2019')).toBeInTheDocument();
expect(queryByText('quuz 13/09/2019')).toBeInTheDocument();
expect(queryByText('blee')).toBeInTheDocument();
});

Expand Down Expand Up @@ -311,7 +311,7 @@ describe('CancelSubscriptionPanel', () => {
'$20.00 + $3.00 tax fooly'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $5.00 + $1.23 taxes is due due 09/13/2019'
'Your next bill of $5.00 + $1.23 taxes is due due 13/09/2019'
);
expect(queryByText('blee')).toBeInTheDocument();
});
Expand Down Expand Up @@ -342,7 +342,7 @@ describe('CancelSubscriptionPanel', () => {
'$20.00 + $3.00 tax barly 8 24hrs'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $5.00 + $1.23 taxes is due due 08/14/2019'
'Your next bill of $5.00 + $1.23 taxes is due due 14/08/2019'
);
});

Expand Down Expand Up @@ -371,7 +371,7 @@ describe('CancelSubscriptionPanel', () => {
'$19.50 + $3.00 tax barly 8 24hrs'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $4.50 + $1.23 taxes is due due 08/14/2019'
'Your next bill of $4.50 + $1.23 taxes is due due 14/08/2019'
);
});

Expand All @@ -385,6 +385,7 @@ describe('CancelSubscriptionPanel', () => {
'payment-cancel-btn = blee',
`price-details-tax = { $priceAmount } + { $taxAmount } taxes`,
`sub-next-bill-tax = Your next bill of { $priceAmount } + { $taxAmount } taxes is due due <strong>{ $date }</strong>`,
`sub-next-bill-no-tax = Your next bill of { $priceAmount } prices is due due <strong>{ $date }</strong>`,
].forEach((x) => bundle.addResource(new FluentResource(x)));
const plan = findMockPlan('plan_daily');
render(
Expand All @@ -404,7 +405,7 @@ describe('CancelSubscriptionPanel', () => {
'$20.00 daily'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $5.00 is due 09/13/2019'
'Your next bill of $5.00 prices is due due 13/09/2019'
);
expect(queryByText('blee')).toBeInTheDocument();
});
Expand Down Expand Up @@ -437,7 +438,7 @@ describe('CancelSubscriptionPanel', () => {
'$20.00 fooly'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $5.00 prices is due due 09/13/2019'
'Your next bill of $5.00 prices is due due 13/09/2019'
);
expect(queryByText('blee')).toBeInTheDocument();
});
Expand Down Expand Up @@ -467,7 +468,7 @@ describe('CancelSubscriptionPanel', () => {
'$20.00 barly 8 24hrs'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $5.00 prices is due due 08/14/2019'
'Your next bill of $5.00 prices is due due 14/08/2019'
);
});

Expand Down Expand Up @@ -496,7 +497,7 @@ describe('CancelSubscriptionPanel', () => {
'$19.50 barly 8 24hrs'
);
expect(queryByTestId('sub-next-bill')).toHaveTextContent(
'Your next bill of $4.50 prices is due due 08/14/2019'
'Your next bill of $4.50 prices is due due 14/08/2019'
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const getNextBillData = (
const nextBillDate = getLocalizedDateString(subsequentInvoiceDate, true);
const nextBillL10nVarsDefault = {
priceAmount: getLocalizedCurrency(invoiceDisplayTotal, plan.currency),
date: nextBillDate,
date: getLocalizedDate(subsequentInvoiceDate, true),
};
const nextBillL10nVars = showInvoiceTax
? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from 'fxa-shared/subscriptions/type-guards';
import {
getIapSubscriptionManagementUrl,
getLocalizedDate,
getLocalizedDateString,
} from '../../../lib/formats';

Expand Down Expand Up @@ -48,6 +49,7 @@ const GooglePlaySubscriptionIapItem = (
const { auto_renewing, expiry_time_millis } = customerSubscription;

const nextBillDate = getLocalizedDateString(expiry_time_millis / 1000, true);
const nextBillDateL10n = getLocalizedDate(expiry_time_millis / 1000, true);
const nextBill = `Next billed on ${nextBillDate}`;
const expiresOn = `Expires on ${nextBillDate}`;

Expand All @@ -65,17 +67,11 @@ const GooglePlaySubscriptionIapItem = (
<div className="iap-type">Google: In-App purchase</div>
</Localized>
{auto_renewing ? (
<Localized
id="sub-next-bill"
vars={{ date: nextBillDate as string }}
>
<Localized id="sub-next-bill" vars={{ date: nextBillDateL10n }}>
<div>{nextBill}</div>
</Localized>
) : (
<Localized
id="sub-expires-on"
vars={{ date: nextBillDate as string }}
>
<Localized id="sub-expires-on" vars={{ date: nextBillDateL10n }}>
<div>{expiresOn}</div>
</Localized>
)}
Expand Down Expand Up @@ -103,10 +99,11 @@ const AppleSubscriptionIapItem = (
) => {
const { auto_renewing, expiry_time_millis } = customerSubscription;

let nextBill, expiresOn, nextBillDate;
let nextBill, expiresOn, nextBillDate, nextBillDateL10n;
// TODO - Remove expiry_time_millis check pending https://developer.apple.com/forums/thread/705730
if (expiry_time_millis) {
nextBillDate = getLocalizedDateString(expiry_time_millis / 1000, true);
nextBillDateL10n = getLocalizedDate(expiry_time_millis / 1000, true);
nextBill = `Next billed on ${nextBillDate}`;
expiresOn = `Expires on ${nextBillDate}`;
}
Expand All @@ -126,16 +123,13 @@ const AppleSubscriptionIapItem = (
</Localized>
{!!expiry_time_millis &&
(auto_renewing ? (
<Localized
id="sub-next-bill"
vars={{ date: nextBillDate as string }}
>
<Localized id="sub-next-bill" vars={{ date: nextBillDateL10n }}>
<div>{nextBill}</div>
</Localized>
) : (
<Localized
id="sub-expires-on"
vars={{ date: nextBillDate as string }}
vars={{ date: nextBillDateL10n }}
>
<div>{expiresOn}</div>
</Localized>
Expand Down
51 changes: 46 additions & 5 deletions packages/fxa-react/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,11 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { FluentBundle, FluentVariable } from '@fluent/bundle';
import { FluentBundle, FluentDateTime, FluentVariable } from '@fluent/bundle';
import { Message, Pattern } from '@fluent/bundle/esm/ast';
import { Localized, LocalizedProps, ReactLocalization } from '@fluent/react';
import React from 'react';

export type FtlMsgProps = {
children: React.ReactNode;
} & LocalizedProps;

// Going from react page to non-react page requires a hard navigate. This temporary
// function is an easy way to reference what needs updating when applicable flows have
// been fully converted - we should remove references to this function as we go and use
Expand All @@ -19,6 +15,51 @@ export function hardNavigateToContentServer(href: string) {
window.location.href = href;
}

export enum LocalizedDateOptions {
NumericDate,
NumericDateAndTime,
}

/**
* This method is used to provide Fluent with a localizable value that can be formatted per .ftl file based on localization requirements
*
* @param milliseconds
* @param numericDate
*/
export const getLocalizedDate = (
milliseconds: number,
dateOptions: LocalizedDateOptions
): FluentDateTime => {
let options: Intl.DateTimeFormatOptions | undefined;

switch (dateOptions) {
case LocalizedDateOptions.NumericDate:
options = {
day: 'numeric',
month: 'numeric',
year: 'numeric',
};
break;
case LocalizedDateOptions.NumericDateAndTime:
options = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
break;
default:
options = undefined;
}

return new FluentDateTime(milliseconds, options);
};

export type FtlMsgProps = {
children: React.ReactNode;
} & LocalizedProps;

export const FtlMsg = (props: FtlMsgProps) => (
<Localized {...props}>{props.children}</Localized>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import React from 'react';
import { FtlMsg } from 'fxa-react/lib/utils';
import {
FtlMsg,
LocalizedDateOptions,
getLocalizedDate,
} from 'fxa-react/lib/utils';

enum SecurityEventName {
Create = 'account.create',
Expand Down Expand Up @@ -77,12 +81,22 @@ export function SecurityEvent({
minute: 'numeric',
}).format(new Date(createdAt));

const createdAtDateFluent = getLocalizedDate(
createdAt,
LocalizedDateOptions.NumericDateAndTime
);

const l10nName = getSecurityEventNameL10n(name);
return (
<li className="mt-5 ml-4" data-testid={l10nName.ftlId}>
<div className="absolute w-3 h-3 bg-green-600 rounded-full mt-1.5 -left-1.5 border border-green-700"></div>
<div className="text-grey-900 text-sm mobileLandscape:mt-3">
{createdAtDateText}
<FtlMsg
id="recent-activity-created-at"
vars={{ date: createdAtDateFluent }}
>
{createdAtDateText}
</FtlMsg>
</div>
<FtlMsg id={l10nName.ftlId}>
<p className="text-grey-400 text-xs mobileLandscape:mt-3">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ recent-activity-account-login = Account initiated login
recent-activity-account-reset = Account initiated password reset
recent-activity-emails-clearBounces = Account cleared email bounces

## $date (Date) - Date recent activity was created
recent-activity-created-at = { $date }
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import UnitRowRecoveryKey from '../UnitRowRecoveryKey';
import UnitRowTwoStepAuth from '../UnitRowTwoStepAuth';
import { UnitRow } from '../UnitRow';
import { useAccount } from '../../../models';
import { FtlMsg } from 'fxa-react/lib/utils';
import {
FtlMsg,
getLocalizedDate,
LocalizedDateOptions,
} from 'fxa-react/lib/utils';
import { Link } from '@reach/router';

const PwdDate = ({ passwordCreated }: { passwordCreated: number }) => {
Expand All @@ -18,8 +22,13 @@ const PwdDate = ({ passwordCreated }: { passwordCreated: number }) => {
day: 'numeric',
}).format(new Date(passwordCreated));

const pwdDateFluent = getLocalizedDate(
passwordCreated,
LocalizedDateOptions.NumericDate
);

return (
<FtlMsg id="security-password-created-date" vars={{ date: pwdDateText }}>
<FtlMsg id="security-password-created-date" vars={{ date: pwdDateFluent }}>
Copy link
Contributor

@vpomerleau vpomerleau Jun 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a question for @bcolsson, but wouldn't Fluent handle localization of the date if we passed in a date variable instead of a string?

According to Fluent's documentation :

If the variable passed from the developer is a date and is used in a placeable, FTL will implicitly call a DATETIME function on it.

In most cases, Fluent will automatically select the right formatter for the argument and format it into a given locale.

https://projectfluent.org/fluent/guide/builtins.html

Copy link
Contributor

@bcolsson bcolsson Jun 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that's what the getLocalizedDate function is doing, providing a datetime that can be parsed by fluent:

/**
* This method is used to provide Fluent with a localizable value that can be formatted per .ftl file based on localization requirements
*
* @param milliseconds
* @param numericDate
*/
export const getLocalizedDate = (
milliseconds: number,
dateOptions: LocalizedDateOptions
): FluentDateTime => {
let options: Intl.DateTimeFormatOptions | undefined;
switch (dateOptions) {
case LocalizedDateOptions.NumericDate:
options = {
day: 'numeric',
month: 'numeric',
year: 'numeric',
};
break;
case LocalizedDateOptions.NumericDateAndTime:
options = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
};
break;
default:
options = undefined;
}
return new FluentDateTime(milliseconds, options);
};

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I see that we're using this to pass localization options with the date.

Equivalent to passing the options in more explicitly, as documented? @bcolsson would it be preferable to expose these options to localizers so they can have visibility on the date formatting? If not like below, then at least in a comment?
today-is = Today is { DATETIME($date, month: "long", year: "numeric", day: "numeric") }

@StaberindeZA this is non-blocking BTW - I'm somewhat hijacking your PR to get more details on how to handle date localization in the future :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case of things like correct date and number formatting, typically it is expected that the internationalization layer should handle the correct formatting for each locale without intervention by the linguists.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That being said, I see your point regarding how a date is displayed. We could definitely add a comment if you wanted to include how it appears.

<p className="text-grey-400 text-xs mobileLandscape:mt-3">
Created {pwdDateText}
</p>
Expand Down