diff --git a/libs/payments/email/.eslintrc.json b/libs/payments/email/.eslintrc.json new file mode 100644 index 00000000000..3456be9b903 --- /dev/null +++ b/libs/payments/email/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/payments/email/.swcrc b/libs/payments/email/.swcrc new file mode 100644 index 00000000000..f52b4e44979 --- /dev/null +++ b/libs/payments/email/.swcrc @@ -0,0 +1,14 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + } + } +} diff --git a/libs/payments/email/README.md b/libs/payments/email/README.md new file mode 100644 index 00000000000..485b74d9ce7 --- /dev/null +++ b/libs/payments/email/README.md @@ -0,0 +1,11 @@ +# email + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build payments-email` to build the library. + +## Running unit tests + +Run `nx test payments-email` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/payments/email/jest.config.ts b/libs/payments/email/jest.config.ts new file mode 100644 index 00000000000..c5dafe8a763 --- /dev/null +++ b/libs/payments/email/jest.config.ts @@ -0,0 +1,43 @@ +/* eslint-disable */ +import { readFileSync } from 'fs'; +import { Config } from 'jest'; + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse( + readFileSync(`${__dirname}/.swcrc`, 'utf-8') +); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +const config: Config = { + displayName: 'payments-email', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + testEnvironment: 'node', + coverageDirectory: '../../../coverage/libs/payments/email', + reporters: [ + 'default', + [ + 'jest-junit', + { + outputDirectory: 'artifacts/tests/payments-email', + outputName: 'payments-email-jest-unit-results.xml', + }, + ], + ], +}; + +export default config; diff --git a/libs/payments/email/package.json b/libs/payments/email/package.json new file mode 100644 index 00000000000..3ab5c62e3a8 --- /dev/null +++ b/libs/payments/email/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fxa/payments/email", + "version": "0.0.1" +} diff --git a/libs/payments/email/project.json b/libs/payments/email/project.json new file mode 100644 index 00000000000..1dbc49bda7d --- /dev/null +++ b/libs/payments/email/project.json @@ -0,0 +1,51 @@ +{ + "name": "payments-email", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/payments/email/src", + "projectType": "library", + "tags": ["scope:shared:lib:payments"], + "targets": { + "build": { + "executor": "@nx/esbuild:esbuild", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "main": "libs/payments/email/src/index.ts", + "outputPath": "dist/libs/payments/email", + "outputFileName": "main.js", + "tsConfig": "libs/payments/email/tsconfig.lib.json", + "declaration": true, + "assets": [ + { + "glob": "libs/payments/email/README.md", + "input": ".", + "output": "." + } + ], + "platform": "node" + }, + "configurations": { + "development": { + "minify": false + }, + "production": { + "minify": true + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/payments/email/**/*.ts"] + } + }, + "test-unit": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/payments/email/jest.config.ts" + } + } + } +} diff --git a/libs/payments/email/src/index.ts b/libs/payments/email/src/index.ts new file mode 100644 index 00000000000..36c7c0dc026 --- /dev/null +++ b/libs/payments/email/src/index.ts @@ -0,0 +1,5 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +export * from './lib/emailTemplate.manager'; diff --git a/libs/payments/email/src/lib/emailTemplate.error.ts b/libs/payments/email/src/lib/emailTemplate.error.ts new file mode 100644 index 00000000000..7bbda31e94b --- /dev/null +++ b/libs/payments/email/src/lib/emailTemplate.error.ts @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { BaseError } from '@fxa/shared/error'; + +/** + * EmailTemplateError is not intended for direct use, except for type-checking errors. + * When throwing a new EmailTemplateError, create a unique extension of the class. + */ +export class EmailTemplateError extends BaseError { + constructor(message: string, info: Record, cause?: Error) { + super(message, { + info, + cause, + }); + this.name = 'EmailTemplateError'; + } +} + +export class EmailTemplateRetrieveComponentError extends EmailTemplateError { + constructor(path: string, name: string) { + super(`Failed to retrieve email component ${name} from path: ${path}`, { + path, + name, + }); + this.name = 'EmailTemplateRetrieveComponentError'; + } +} diff --git a/libs/payments/email/src/lib/emailTemplate.manager.spec.ts b/libs/payments/email/src/lib/emailTemplate.manager.spec.ts new file mode 100644 index 00000000000..d850c3dbbbe --- /dev/null +++ b/libs/payments/email/src/lib/emailTemplate.manager.spec.ts @@ -0,0 +1,314 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 'server-only'; +jest.mock('server-only', () => {}); + +// l10n module uses ESM imports +jest.mock('@fxa/shared/l10n/dom', () => ({ + LocalizerServer: jest.fn().mockImplementation(() => ({ + getLocaleFromAcceptLanguage: jest.fn().mockReturnValue('en'), + formatMessagesSync: jest + .fn() + .mockImplementation((messages: any[]) => + messages.map((msg: any) => ({ value: `Localized: ${msg.id}` })) + ), + })), + LocalizerDom: jest.fn().mockImplementation(() => ({ + localize: jest.fn().mockImplementation((element: any) => element), + })), + determineDirection: jest.fn().mockReturnValue('ltr'), +})); +const { LocalizerServer } = jest.requireMock('@fxa/shared/l10n/dom'); + +import { Test } from '@nestjs/testing'; +import { promises as fs } from 'fs'; + +import { EmailTemplateManager } from './emailTemplate.manager'; +import { createRenderEmailOptions } from './factories/emailTemplate.factory'; + +const mockTemplateMjml = ` +<%# This Source Code Form is subject to the terms of the Mozilla Public + # 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/. %> + + + + + Thank you for upgrading! + + + + You have successfully upgraded to <%- productName %>. + + + + <% if (locals.paymentProratedInCents < 0) { %> + You have received an account credit in the amount of <%- paymentProrated %>. + <% } else if (locals.paymentProratedInCents > 0) { %> + You have been charged a one-time fee of <%- invoiceAmountDue %> to reflect your subscription’s higher price for the remainder of this billing period (<%- productPaymentCycleOld %>). + <% } %> + + Starting with your next bill, the price of your subscription will change. + + + <%= previousRate.message %> + + + <%= newRate.message %> + + + + + If any of your existing subscriptions overlap with this upgrade, we’ll handle them and send you a separate email with the details. If your new plan includes products that require installation, we’ll send you a separate email with setup instructions. + + + + Your subscription will automatically renew each billing period unless you choose to cancel. + + + +`; + +const mockTemplateText = ` +subscriptionUpgrade-subject = "You have upgraded to <%- productName %>" + +subscriptionUpgrade-title = "Thank you for upgrading!" + +subscriptionUpgrade-upgrade-info-2 = "You have successfully upgraded to <%- productName %>." + +<% if (locals.paymentProratedInCents < 0) { %> +subscriptionUpgrade-content-charge-credit = "You have received an account credit in the amount of <%- paymentProrated %>." +<% } else if (locals.paymentProratedInCents > 0) { %> +subscriptionUpgrade-content-charge-prorated-1 = "You have been charged a one-time fee of <%- invoiceAmountDue %> to reflect your subscription’s higher price for the remainder of this billing period (<%- productPaymentCycleOld %>)." +<% } %> +subscriptionUpgrade-content-subscription-will-change = "Starting with your next bill, the price of your subscription will change." +<% if (locals.paymentTaxOldInCents) { %> +<% if (locals.productPaymentCycleOld === 'day') { %> +subscriptionUpgrade-content-old-price-day-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per day." +<% } else if (locals.productPaymentCycleOld === 'week') { %> +subscriptionUpgrade-content-old-price-week-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per week." +<% } else if (locals.productPaymentCycleOld === 'month') { %> +subscriptionUpgrade-content-old-price-month-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per month." +<% } else if (locals.productPaymentCycleOld === 'halfyear') { %> +subscriptionUpgrade-content-old-price-halfyear-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per six months." +<% } else if (locals.productPaymentCycleOld === 'year') { %> +subscriptionUpgrade-content-old-price-year-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per year." +<% } else { %> +subscriptionUpgrade-content-old-price-default-tax = "The previous rate was <%- paymentAmountOld %> + <%- paymentTaxOld %> tax per billing interval." +<% } %> +<% } else { %> +<% if (locals.productPaymentCycleOld === 'day') { %> +subscriptionUpgrade-content-old-price-day = "The previous rate was <%- paymentAmountOld %> per day." +<% } else if (locals.productPaymentCycleOld === 'week') { %> +subscriptionUpgrade-content-old-price-week = "The previous rate was <%- paymentAmountOld %> per week." +<% } else if (locals.productPaymentCycleOld === 'month') { %> +subscriptionUpgrade-content-old-price-month = "The previous rate was <%- paymentAmountOld %> per month." +<% } else if (locals.productPaymentCycleOld === 'halfyear') { %> +subscriptionUpgrade-content-old-price-halfyear = "The previous rate was <%- paymentAmountOld %> per six months." +<% } else if (locals.productPaymentCycleOld === 'year') { %> +subscriptionUpgrade-content-old-price-year = "The previous rate was <%- paymentAmountOld %> per year." +<% } else { %> +subscriptionUpgrade-content-old-price-default = "The previous rate was <%- paymentAmountOld %> per billing interval." +<% } %> +<% } %> +<% if (locals.paymentTaxNewInCents) { %> +<% if (locals.productPaymentCycleNew === 'day') { %> +subscriptionUpgrade-content-new-price-day-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per day." +<% } else if (locals.productPaymentCycleNew === 'week') { %> +subscriptionUpgrade-content-new-price-week-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per week." +<% } else if (locals.productPaymentCycleNew === 'month') { %> +subscriptionUpgrade-content-new-price-month-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per month." +<% } else if (locals.productPaymentCycleNew === 'halfyear') { %> +subscriptionUpgrade-content-new-price-halfyear-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per six months." +<% } else if (locals.productPaymentCycleNew === 'year') { %> +subscriptionUpgrade-content-new-price-year-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per year." +<% } else { %> +subscriptionUpgrade-content-new-price-default-tax = "Going forward, you will be charged <%- paymentAmountNew %> + <%- paymentTaxNew %> tax per billing interval." +<% } %> +<% } else { %> +<% if (locals.productPaymentCycleNew === 'day') { %> +subscriptionUpgrade-content-new-price-day = "Going forward, you will be charged <%- paymentAmountNew %> per day." +<% } else if (locals.productPaymentCycleNew === 'week') { %> +subscriptionUpgrade-content-new-price-week = "Going forward, you will be charged <%- paymentAmountNew %> per week." +<% } else if (locals.productPaymentCycleNew === 'month') { %> +subscriptionUpgrade-content-new-price-month = "Going forward, you will be charged <%- paymentAmountNew %> per month." +<% } else if (locals.productPaymentCycleNew === 'halfyear') { %> +subscriptionUpgrade-content-new-price-halfyear = "Going forward, you will be charged <%- paymentAmountNew %> per six months." +<% } else if (locals.productPaymentCycleNew === 'year') { %> +subscriptionUpgrade-content-new-price-year = "Going forward, you will be charged <%- paymentAmountNew %> per year." +<% } else { %> +subscriptionUpgrade-content-new-price-default = "Going forward, you will be charged <%- paymentAmountNew %> per billing interval." +<% } %> +<% } %> + +subscriptionUpgrade-existing = "If any of your existing subscriptions overlap with this upgrade, we’ll handle them and send you a separate email with the details. If your new plan includes products that require installation, we’ll send you a separate email with setup instructions." + +subscriptionUpgrade-auto-renew = "Your subscription will automatically renew each billing period unless you choose to cancel." +`; + +const mockLayoutMjml = ` +<%# This Source Code Form is subject to the terms of the Mozilla Public +# 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/. %> + + + + + <% if (locals.productIconURLNew) { %> + + + + + <% } else { %> + + + + <% } %> + + + <%- body %> + + + + + + Questions about your subscription? Our support team is here to help you. + + + + + + +`; + +const mockLayoutText = ` +subscriptionSupport-plaintext = "Questions about your subscription? Our support team is here to help you:" +<%- subscriptionSupportUrl %> +`; + +const mockIncludes = { + subject: { + id: 'subscriptionUpgrade-subject', + message: 'You have upgraded to <%- productName %>', + }, +}; + +describe('EmailTemplateManager', () => { + let emailTemplateManager: EmailTemplateManager; + + beforeEach(async () => { + const mockLocalizerServer = { + setupDomLocalization: jest.fn().mockResolvedValue({ + l10n: { + connectRoot: jest.fn(), + translateRoots: jest.fn().mockReturnValue(undefined), + formatValue: jest.fn().mockResolvedValue('Localized Subject'), + }, + selectedLocale: 'en-US', + }), + getLocaleFromAcceptLanguage: jest.fn().mockReturnValue('en'), + formatMessagesSync: jest + .fn() + .mockImplementation((messages: any[]) => + messages.map((msg: any) => ({ value: `Localized: ${msg.id}` })) + ), + }; + + const moduleRef = await Test.createTestingModule({ + providers: [ + { provide: LocalizerServer, useValue: mockLocalizerServer }, + EmailTemplateManager, + ], + }).compile(); + + emailTemplateManager = moduleRef.get(EmailTemplateManager); + + jest.spyOn(fs, 'readFile').mockImplementation(async (path: any) => { + let fileContent = ''; + if (path.toString().includes('includes.json')) { + fileContent = JSON.stringify(mockIncludes); + } + if ( + path.toString().includes('index.mjml') && + path.toString().includes('template') + ) { + fileContent = mockTemplateMjml; + } + if ( + path.toString().includes('index.txt') && + path.toString().includes('template') + ) { + fileContent = mockTemplateText; + } + if ( + path.toString().includes('index.mjml') && + path.toString().includes('layout') + ) { + fileContent = mockLayoutMjml; + } + if ( + path.toString().includes('index.txt') && + path.toString().includes('layout') + ) { + fileContent = mockLayoutText; + } + + return Promise.resolve(fileContent); + }); + }); + + describe('renderEmail', () => { + it('should render an email template successfully', async () => { + const options = createRenderEmailOptions({ + template: 'test', + layout: 'layout', + args: { + userName: 'John Doe', + productName: 'Pro Plan', + subscriptionSupportUrl: 'https://example.com/support', + previousRate: { + l10nId: 'subscriptionUpgrade-content-old-price-month', + l10nArgs: { + paymentAmountOld: '$5.00', + }, + message: 'The previous rate was $5.00 per month.', + }, + newRate: { + l10nId: 'subscriptionUpgrade-content-new-price-month', + l10nArgs: { + paymentAmountNew: '$10.00', + }, + message: 'Going forward, you will be charged $10.00 per month.', + }, + paymentProratedInCents: 250, + paymentProrated: '$2.50', + invoiceAmountDue: '$2.50', + productPaymentCycleOld: 'month', + productPaymentCycleNew: 'month', + icon: 'https://example.com/icon.png', + }, + }); + + const result = await emailTemplateManager.renderEmail(options); + expect(result.html).toContain( + 'Pro Plan> = { + template: string; + layout: string; + acceptLanguage: string; + selectedLocale?: string; + subject: string; + args: T & Record; +}; + +type SubscriptionFirstInvoiceArgs = { + email: string; + uid: string; + productId: string; + planId: string; + planEmailIconURL: string; + productName: string; + creditAppliedInCents: string; + invoiceAmountDueInCents: string; + invoiceNumber: string; + invoiceDate: string; + invoiceLink: string; + invoiceTotalInCents: string; + invoiceTotalCurrency: string; + invoiceStartingBalance: string; + invoiceSubtotalInCents: string; + invoiceDiscountAmountInCents: string; + invoiceTaxAmountInCents: string; + offeringPriceInCents: string; + payment_provider: string; + cardType: string; + lastFour: string; + nextInvoiceDate: string; + remainingAmountInCents: string; + showTaxAmount: string; + unusedAmountTotalInCents: string; + discountType: string; + discountDuration: string; +}; + +@Injectable() +export class EmailTemplateManager { + private readonly templateBasePath: string; + private readonly cssPath: string; + private templateCache: Map = new Map(); + private layoutCache: Map = new Map(); + + constructor( + @Inject(LocalizerServer) private localizerServer: LocalizerServer + ) { + this.templateBasePath = join(__dirname, '../templates'); + this.cssPath = join( + __dirname, + '../../../../../packages/fxa-auth-server/lib/senders/emails/css' + ); + } + + // emailTemplateManager.renderEmail(renewalEmail, args) should enforce typing on args + + // async renderEmail(options: RenderEmailOptions): Promise { + async renderEmail>( + options: RenderEmailOptions + ): Promise { + const { + template, + layout, + acceptLanguage, + selectedLocale, + args: templateValues, + } = options; + const { l10n } = + await this.localizerServer.setupDomLocalization(acceptLanguage); + + const localizedGlobalTemplateValues = await this.getLocalizedEjsLocalValues( + template, + l10n, + templateValues + ); + + const rendererContext: RendererContext = { + ...templateValues, + ...localizedGlobalTemplateValues, + template, + layout, + acceptLanguage, + selectedLocale, + cssPath: this.cssPath, + }; + + const { templateComponent, layoutComponent } = await this.loadFiles( + template, + layout + ); + + const { renderedTemplate, plaintext } = this.renderEjsContent( + templateComponent, + layoutComponent, + rendererContext + ); + + const html = mjml2html(renderedTemplate, { + filePath: rendererContext.cssPath, + validationLevel: 'strict', + keepComments: false, + }); + + const dom = new JSDOM(html.html); + const rootElement = dom.window.document.documentElement; + + l10n.connectRoot(rootElement); + await l10n.translateRoots(); + + const direction = determineDirection( + acceptLanguage || selectedLocale || 'en' + ); + const isRtl = direction === 'rtl'; + if (isRtl) { + const body = rootElement.getElementsByTagName('body')[0]; + if (body) { + body.classList.add('rtl'); + } + } + + const localizedPlaintext = await this.localizeDomPlaintext( + plaintext, + rendererContext, + l10n + ); + + return { + html: rootElement.outerHTML, + text: localizedPlaintext, + subject: rendererContext.subject, + action: rendererContext.action || '', + preview: rendererContext.preview || '', + }; + } + + // Load MJML and plaintext template/layout files, with caching + async loadFiles(template: string, layout: string) { + let templateComponent = this.templateCache.get(template); + let layoutComponent = this.layoutCache.get(layout); + this.templateCache.has(template); + if (!templateComponent) { + const templatePath = join(this.templateBasePath, 'templates', template); + const templateMjmlPath = join(templatePath, 'index.mjml'); + const templateTextPath = join(templatePath, 'index.txt'); + templateComponent = { + mjml: await fs.readFile(templateMjmlPath, 'utf8'), + text: await fs.readFile(templateTextPath, 'utf8'), + }; + this.templateCache.set(template, templateComponent); + } + if (!layoutComponent) { + const layoutPath = join(this.templateBasePath, 'layouts', layout); + const layoutMjmlPath = join(layoutPath, 'index.mjml'); + const layoutTextPath = join(layoutPath, 'index.txt'); + layoutComponent = { + mjml: await fs.readFile(layoutMjmlPath, 'utf8'), + text: await fs.readFile(layoutTextPath, 'utf8'), + }; + this.layoutCache.set(layout, layoutComponent); + } + + return { templateComponent, layoutComponent }; + } + + renderEjsContent( + templateComponent: MJMLComponent, + layoutComponent: MJMLComponent, + rendererContext: RendererContext + ) { + const flattenedContext = { + ...rendererContext, + ...this.flattenNestedObjects(rendererContext), + }; + + // const newRender = ejs.render(emaildetailsMjml, { ...flattenedContext, layoutPath }); + + let renderedTemplate = ejs.render( + templateComponent.mjml, + flattenedContext, + { + root: this.templateBasePath, + } + ); + let plaintext = ejs.render(templateComponent.text, flattenedContext, { + root: this.templateBasePath, + }); + + if (layoutComponent) { + renderedTemplate = ejs.render( + layoutComponent.mjml, + { ...flattenedContext, body: renderedTemplate }, + { root: this.templateBasePath } + ); + plaintext = ejs.render( + layoutComponent.text, + { ...flattenedContext, body: plaintext }, + { root: this.templateBasePath } + ); + } + return { renderedTemplate, plaintext }; + } + + // Get the localized template locals for EJS rendering + async getLocalizedEjsLocalValues>( + template: string, + l10n: LocalizerDom, + context: T + ): Promise { + const includesPath = join(this.templateBasePath, template, 'includes.json'); + const includesContent = await fs.readFile(includesPath, 'utf8'); + const globalTemplateValues: GlobalTemplateValues = + JSON.parse(includesContent); + const subject = await this.localizeString( + l10n, + globalTemplateValues.subject, + context + ); + + let action: string | undefined; + if (globalTemplateValues.action) { + action = await this.localizeString( + l10n, + globalTemplateValues.action, + context + ); + } + + let preview: string | undefined; + if (globalTemplateValues.preview) { + preview = await this.localizeString( + l10n, + globalTemplateValues.preview, + context + ); + } + + return { + subject, + action, + preview, + }; + } + + protected async localizeDomPlaintext( + text: string, + context: RendererContext, + domLocalizer: LocalizerDom + ): Promise { + const ftlContext = this.flattenNestedObjects(context); + + const plainTextArr = text.split('\n'); + for (const i in plainTextArr) { + const { key, val } = this.splitPlainTextLine(plainTextArr[i]); + + if (key && val) { + plainTextArr[i] = + (await domLocalizer.formatValue(key, ftlContext)) || val; + } + } + return plainTextArr.join('\n').replace(/(\n){2,}/g, '\n'); + } + + private splitPlainTextLine(plainText: string) { + const reSplitLine = /(?[a-zA-Z0-9-_]+)\s*=\s*"(?.*)?"/; + const matches = reSplitLine.exec(plainText); + const key = matches?.groups?.key; + const val = matches?.groups?.val; + + return { key, val }; + } + + private async localizeString( + l10n: LocalizerDom, + ftlMsg: FtlIdMsg, + localizationParams: Record + ): Promise { + const localizedString = await l10n.formatValue( + ftlMsg.id, + this.flattenNestedObjects(localizationParams), + ftlMsg.message + ); + + // Handle EJS templates in localized strings + if (localizedString.includes('<%')) { + return ejs.render(localizedString, localizationParams, { + root: this.templateBasePath, + }); + } + + return localizedString; + } + + /* + * We flatten objects coming from the mailer when localizing because Fluent expects to be passed + * a simple object containing variable names and their values, not an object containing objects + * + * NOTE: if in the future, any template value is an _array_ of objects containing strings needing + * l10n, we will need to account for those variable names differently to ensure the same EJS + * variable matches the variable name we pass to Fluent. Right now `subscriptions` containing + * `productName`s is the only array of objects and since we don't localize `productName` + * that case isn't handled since we don't need to (yet). + */ + private flattenNestedObjects( + context: RendererContext | Record + ): Record { + const flattenedObj = {} as any; + + for (const templateVar in context) { + const varValue = context[templateVar]; + if (typeof varValue === 'object' && varValue !== null) { + Object.assign(flattenedObj, this.flattenNestedObjects(varValue)); + } else { + flattenedObj[templateVar] = varValue; + } + } + + return flattenedObj; + } +} diff --git a/libs/payments/email/src/lib/emailTemplate.types.ts b/libs/payments/email/src/lib/emailTemplate.types.ts new file mode 100644 index 00000000000..42bc6f789e1 --- /dev/null +++ b/libs/payments/email/src/lib/emailTemplate.types.ts @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +export interface MJMLComponent { + mjml: string; + text: string; +} + +export interface FtlIdMsg { + id: string; + message: string; + vars?: Record; +} + +export interface RendererContext { + template: string; + layout: string; + acceptLanguage?: string; + selectedLocale?: string; + cssPath: string; + subject: string; + action?: string; + preview?: string; + [key: string]: any; // flattened template values +} + +export interface RenderEmailOptions { + template: string; + layout: string; + acceptLanguage: string; + selectedLocale?: string; + args: Record; + subject: string; +} + +export interface EmailRenderResult { + html: string; + text: string; + subject: string; + action: string; + preview: string; +} + +export interface GlobalTemplateValues { + subject: FtlIdMsg; + action?: FtlIdMsg; + preview?: FtlIdMsg; +} + +export interface LocalizedEmailMetadata { + subject: string; + action?: string; + preview?: string; +} diff --git a/libs/payments/email/src/lib/factories/emailTemplate.factory.ts b/libs/payments/email/src/lib/factories/emailTemplate.factory.ts new file mode 100644 index 00000000000..b296d47b597 --- /dev/null +++ b/libs/payments/email/src/lib/factories/emailTemplate.factory.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { faker } from '@faker-js/faker'; +import { RenderEmailOptions } from '../emailTemplate.types'; + +export const createRenderEmailOptions = ( + overrides: Partial = {} +): RenderEmailOptions => ({ + acceptLanguage: faker.helpers.arrayElement(['en-US', 'fr-FR', 'es-ES']), + template: faker.string.alphanumeric(10), + layout: faker.string.alphanumeric(10), + subject: faker.lorem.sentence(), + args: { + [faker.string.alpha(5)]: faker.string.sample(), + }, + ...overrides, +}); diff --git a/libs/payments/email/src/lib/global.scss b/libs/payments/email/src/lib/global.scss new file mode 100644 index 00000000000..9437457d1d5 --- /dev/null +++ b/libs/payments/email/src/lib/global.scss @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +// Fonts +$font-sans: sans-serif; + +// Colors +$blue-500: #0060df; +$white: #fff; +$black: #000; +$grey-50: #f0f0f4; +$grey-100: #e7e7e7; +$grey-400: #6d6d6e; +$grey-500: #4b5563; +$grey-600: #464646; +$purple-600: #7542e5; + +// Spacing values for margin and padding +$s-0: 0px; +$s-1: 4px; +$s-2: 8px; +$s-3: 12px; +$s-4: 16px; +$s-5: 20px; +$s-6: 24px; +$s-8: 32px; +$s-10: 40px; + +// Font-size and line-height +.text { + &-xs { + font-size: 12px !important; + line-height: 20px !important; + } + &-sm { + font-size: 14px !important; + line-height: 22px !important; + } + &-md { + font-size: 16px !important; + line-height: 24px !important; + } + &-lg { + font-size: 18px !important; + line-height: 26px !important; + } + &-xl { + font-size: 20px !important; + line-height: 28px !important; + } + &-2xl { + font-size: 22px !important; + line-height: 30px !important; + } + &-3xl { + font-size: 32px !important; + line-height: 36px !important; + } +} + +// Utility classes +.font-sans { + font-family: $font-sans !important; +} + +.text-blue-500 { + color: $blue-500; +} + +.mt { + &-2 { + margin-top: $s-2 !important; + } + &-4 { + margin-top: $s-4 !important; + } + &-5 { + margin-top: $s-5 !important; + } + &-6 { + margin-top: $s-6 !important; + } + &-8 { + margin-top: $s-8 !important; + } + &-10 { + margin-top: $s-10 !important; + } +} + +.mb { + &-0 { + margin-bottom: $s-0 !important; + } + &-2 { + margin-bottom: $s-2 !important; + } + &-3 { + margin-bottom: $s-3 !important; + } + &-4 { + margin-bottom: $s-4 !important; + } + &-5 { + margin-bottom: $s-5 !important; + } + &-6 { + margin-bottom: $s-6 !important; + } + &-8 { + margin-bottom: $s-8 !important; + } +} + +.px { + &-6 { + padding-left: $s-6 !important; + padding-right: $s-6 !important; + } +} + +.p { + &-4 { + padding: $s-4 !important; + } + &b-1 { + padding-bottom: $s-1 !important; + } + &b-2 { + padding-bottom: $s-2 !important; + } +} +// Global styles +tbody > td:first-child, +tr > td:first-child { + padding: $s-0 !important; +} + +.link-blue { + @extend .text-blue-500; + text-decoration: underline; + font-family: $font-sans; +} + +%text-header-common { + @extend .font-sans; + text-align: center !important; +} + +%text-body-common { + @extend .font-sans; +} + +%banner-common { + max-width: 640px !important; + width: 100% !important; +} + +%text-banner-common { + @extend %text-header-common; + @extend .text-xs; + @extend .p-4; + color: $black !important; + font-weight: 700 !important; +} + +%link-banner-common { + color: $black !important; + text-decoration: underline !important; +} +.align-left div { + text-align: left !important; +} + +.hidden { + display: none; + width: 0; + height: 0; + max-height: 0; + line-height: 0; + overflow: hidden; +} + +.text-footer div { + @extend .font-sans; + text-align: center !important; + color: $grey-400 !important; +} + +.text-footer-automatedEmail { + @extend .text-footer; + div { + @extend .mb-3; + @extend .mt-10; + } +} diff --git a/libs/payments/email/src/lib/layouts/subscription/index.mjml b/libs/payments/email/src/lib/layouts/subscription/index.mjml new file mode 100644 index 00000000000..34590eb0e72 --- /dev/null +++ b/libs/payments/email/src/lib/layouts/subscription/index.mjml @@ -0,0 +1,207 @@ +<%# This Source Code Form is subject to the terms of the Mozilla Public + # 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/. %> + + + + + + + <%- locals.subject %> + <%- locals.preview %> + <% if (!locals.productName) { locals.productName = 'Firefox' %><% } %> + + + fxa-header-mozilla-logo + + + mozilla-logo + + + + fxa-header-sync-devices-image + + + sync-devices-image + + + + subplat-header-mozilla-logo-2 + + + subplat-mozilla-logo + + + + subplat-footer-mozilla-logo-2 + + + mozilla-logo-footer + + + + body-devices-image + + + devices-image + + + body-devices-image + + + devices-image + + + + body-android-badge + <%= JSON.stringify({productName}) %> + + + google-play-badge + + + + body-ios-badge + <%= JSON.stringify({productName}) %> + + + apple-app-badge + + + + <% if (locals.oneClickLink) { %> + + <% } %> + + + + + + + + <% if (locals.brandMessagingMode == 'postlaunch') { %> + + + + + + Did you know we changed our name from Firefox accounts to Mozilla accounts? + Learn more + + + + + <% } %> + + + + + + + + + + <%- body %> + + + + + <% if (locals.productName) { %> + + You’re receiving this email because <%- email %> has a Mozilla account and you signed up for <%- productName %>. + + <% } else if (locals.reminderShortForm) { %> + + You’re receiving this email because <%- email %> has a Mozilla account. + + <% } else if (locals.wasDeleted) { %> + + You’re receiving this email because <%- email %> was registered for a Mozilla account. + + <% } else { %> + + You’re receiving this email because <%- email %> has a Mozilla account and you have subscribed to multiple products. + + <% } %> + + + <% if (!locals.reminderShortForm && !locals.wasDeleted) { %> + + + Manage your Mozilla account settings by visiting your account page. + + + + + <% if (locals.productName || locals.subscriptions?.length > 0) { %> + Terms and cancellation policy +   •   + + Privacy notice + <% } %> + + <% if (!locals.isFinishSetup) { %> +   •   + <% if (locals.isCancellationEmail) { %> + Reactivate subscription +   •   + <% } else { %> + Cancel subscription +   •   + <% } %> + + Update billing information + <% } %> + + <% } else { %> + + Mozilla Accounts Privacy Notice +   •   + + Mozilla Accounts Terms of Service + + <% } %> + + + + + 149 New Montgomery St, 4th Floor, San Francisco, CA 94105 + + + Legal +   •   + Privacy + + + + + + diff --git a/libs/payments/email/src/lib/locale-dir.scss b/libs/payments/email/src/lib/locale-dir.scss new file mode 100644 index 00000000000..53850d17965 --- /dev/null +++ b/libs/payments/email/src/lib/locale-dir.scss @@ -0,0 +1,17 @@ +.ltr { + div { + direction: ltr !important; + } + td { + direction: ltr !important; + } +} + +.rtl { + div { + direction: rtl !important; + } + td { + direction: rtl !important; + } +} diff --git a/libs/payments/email/src/lib/mjml-browser-helper.ts b/libs/payments/email/src/lib/mjml-browser-helper.ts new file mode 100644 index 00000000000..8e2f62aa0a8 --- /dev/null +++ b/libs/payments/email/src/lib/mjml-browser-helper.ts @@ -0,0 +1,114 @@ +type MjIncludeTag = { path?: string; inline: boolean; type?: string }; + +/** + * Important! At the current momement, mjml-browser does not support . + * See: https://github.com/mjmlio/mjml/tree/master/packages/mjml-browser#unavailable-features + * + * Until this is supported, we will convert mj-incude tags into mj-style tags with + * ejs includes. The allows parity between mjml and mjml-browser. + * @param mjml mjml document + * @returns mjml document that can be processed by mjml-browser + */ +export function transformMjIncludeTags(mjml: string): string { + // Must be mjml document + const hasOpeningMjmlTag = //.test(mjml); + if (!hasOpeningMjmlTag) { + throw new Error('Missing tag'); + } else if (!hasClosingMjmlTag) { + throw new Error('Missing tag'); + } + + // tags must go in header. Create one if possible, + // otherwise error out. + const hasOpeningMjHeadTag = //.test(mjml); + const hasClosingMjHeadTag = /<\/mj-head>/.test(mjml); + if (!hasOpeningMjHeadTag && !hasClosingMjHeadTag) { + mjml = mjml.replace('', ` `); + } else if (!hasOpeningMjHeadTag) { + throw new Error('Missing tag'); + } else if (!hasClosingMjHeadTag) { + throw new Error('Missing tag'); + } + + // Parse mjml and build style statements using ejs includes + const includes = extractMjIncludeTags(mjml); + + // Append includes to end of header + if (includes.length) { + // Update the header tag, appending the includes + mjml = mjml.replace( + /<\/mj-head>/, + `${includes.map((x) => toMjStyle(x)).join('')}` + ); + } + return mjml; +} + +function extractMjIncludeTags(mjml: string): MjIncludeTag[] { + let chomp = false; + let include = ''; + const includes = new Array(); + mjml + .replace(/ { + if (chomp && / tag'); + } + + // Keep adding text while, chomping + if (chomp) { + include += x; + } + // Find start tag and begin chomping + else if (//.test(include)) { + chomp = false; + } + + // If done chomping and include tag, parse it + if (include && !chomp) { + includes.push(parseMjIncludeTag(include)); + include = ''; + } + }); + + // Inidcates /> is missing + if (chomp) { + throw new Error('Malformed tag'); + } + + return includes; +} + +function parseMjIncludeTag(include: string): MjIncludeTag { + const res = { + path: /path=("[^"]*|'[^']*)/g.exec(include)?.[1]?.substring(1), + inline: /css-inline=("inline"|'inline')/g.test(include), + type: /type=("[^"]*|'[^']*)/g.exec(include)?.[1]?.substring(1), + }; + + // Convert relative paths. The requests will now be made to the root + // of the webserver. + res.path = res.path?.replace(/\.\//, '/'); + + return res; +} + +function toMjStyle(tag: MjIncludeTag) { + const { inline, path, type } = tag; + + if (type !== 'css') return ''; + if (!path) return ''; + + if (inline) { + return ` <%- include('${path}') %> `; + } + return ` <%- include('${path}') %> `; +} diff --git a/libs/payments/email/src/lib/sass-compile-files.ts b/libs/payments/email/src/lib/sass-compile-files.ts new file mode 100644 index 00000000000..70680755e8c --- /dev/null +++ b/libs/payments/email/src/lib/sass-compile-files.ts @@ -0,0 +1,69 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { renderSync } from 'sass'; +import { + writeFileSync, + mkdirSync, + existsSync, + rmdirSync, + readdirSync, +} from 'fs'; +import path from 'path'; + +const getDirectories = (source: string) => + readdirSync(source, { withFileTypes: true }) + .filter((dirent: any) => dirent.isDirectory()) + .map((dirent: any) => dirent.name); + +// construct arrays of partials and templates based on the directories +const partials = getDirectories(path.join(__dirname, 'partials')); +const templates = getDirectories(path.join(__dirname, 'templates')); +const layouts = getDirectories(path.join(__dirname, 'layouts')); + +async function compileSass(dir: string, subdir: string) { + let styleResult: Record = {}; + try { + styleResult = renderSync({ + file: dir, + outFile: subdir, + }); + } catch (e) { + console.log(e); + } + writeFileSync(subdir, styleResult.css, 'utf8'); +} + +async function main(directories: Record) { + // remove css directory if already present + if (existsSync(path.join(__dirname, 'css'))) { + rmdirSync(path.join(__dirname, 'css'), { recursive: true }); + } + mkdirSync(path.join(__dirname, 'css')); + + // create subdirectories for templates and partials inside the css directory and generate compiled stylesheets into them + for (const dir in directories) { + for (const subdir of directories[`${dir}`]) { + mkdirSync(path.join(__dirname, 'css', subdir)); + const scssPath = path.join(__dirname, dir, subdir, 'index.scss'); + const cssPath = path.join(__dirname, 'css', subdir, 'index.css'); + if (existsSync(scssPath)) { + await compileSass(scssPath, cssPath); + } + } + } + + // compile the global stylesheet + await compileSass( + path.join(__dirname, 'global.scss'), + path.join(__dirname, 'css', 'global.css') + ); + + await compileSass( + path.join(__dirname, 'locale-dir.scss'), + path.join(__dirname, 'css', 'locale-dir.css') + ); +} + +main({ partials, templates, layouts }); diff --git a/libs/payments/email/src/lib/templates/subscriptionFirstInvoice/index.mjml b/libs/payments/email/src/lib/templates/subscriptionFirstInvoice/index.mjml new file mode 100644 index 00000000000..4cbfc579b58 --- /dev/null +++ b/libs/payments/email/src/lib/templates/subscriptionFirstInvoice/index.mjml @@ -0,0 +1,306 @@ +<%# This Source Code Form is subject to the terms of the Mozilla Public +# 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/. %> + + + <% if (locals.productIconURLNew) { %> + + + + + <% } else { %> + + + + <% } %> + + + + + + Thank you for subscribing to <%- productName %> + + + + + Your payment is currently processing and may take up to four business days to complete. + + + + + + You will receive a separate email on how to start using <%- productName %>. + + + + + + Your subscription will automatically renew each billing period unless you choose to cancel. + + + + + + Invoice Summary + + + + + + Invoice number: <%- invoiceNumber %> + + + + + + Date: <%- invoiceDateOnly %> + + + + <% if (invoiceAmountDueInCents > 0) { %> + <% if (paymentProviderName) { %> + + + Payment method: <%- paymentProviderName %> + + + <% } else { %> + <% if (cardName && lastFour) { %> + + + Payment method: <%- cardName %> ending in <%- lastFour %> + + + <% } else { %> + + + Payment method: Card ending in <%- lastFour %> + + + <% } %> + <% } %> + <% } %> + + <% if (remainingAmountTotalInCents && offeringPriceInCents !== remainingAmountTotalInCents) { %> + + + + + + +
+ + Prorated price + + + + <%- remainingAmountTotal %> + +
+
+ <% } else { %> + + + + + + +
+ + List price + + + + <%- offeringPrice %> + +
+
+ <% } %> + + <% if (!!unusedAmountTotalInCents) { %> + + + + + + +
+ + Credit from unused time + + + + <%- unusedAmountTotal %> + +
+
+ + <% if (invoiceSubtotalInCents !== invoiceTotalInCents) { %> + + + + + + +
+ + Subtotal + + + + <%- invoiceSubtotal %> + +
+
+ <% } %> + <% } %> + + <% if (discountType && invoiceDiscountAmount) { %> + + + + + + +
+ <% if (discountType==='once' ) { %> + + One-time discount + + <% } else if (discountType==='repeating' ) { %> + + <%discountDuration%>-month discount + + <% } else { %> + + Discount + + <% } %> + + + <%- invoiceDiscountAmount %> + +
+
+ <% } %> + + <% if (showTaxAmount && invoiceTaxAmountInCents > 0 ) { %> + + + + + + +
+ + Taxes & fees + + + + <%- invoiceTaxAmount %> + +
+
+ <% } %> + + <% if (invoiceTotalInCents !== invoiceAmountDueInCents) { %> + + + + + + +
+ + Total + + + + <%- invoiceTotal %> + +
+
+ <% } %> + + <% if (!!creditAppliedInCents && invoiceStartingBalance < 0) { %> + + + + + + +
+ + Credit applied + + + + <%- creditApplied %> + +
+
+ <% } %> + + + + + + + +
+ + Amount paid + + + + <%- invoiceAmountDue %> + +
+
+ + <% if (locals.invoiceTotalInCents < 0) { %> + +

+ You have received an account credit of <%- creditReceived %>, which will be applied to your future invoices. +

+
+ <% } %> + + View invoice + + + + + Your next invoice will be issued on <%- nextInvoiceDateOnly %>. + + +
+
+ + + + + + Get help with your subscription + + + + + + Manage your subscription + + + + + + Contact support + + + + diff --git a/libs/payments/email/tsconfig.json b/libs/payments/email/tsconfig.json new file mode 100644 index 00000000000..25f7201d870 --- /dev/null +++ b/libs/payments/email/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/payments/email/tsconfig.lib.json b/libs/payments/email/tsconfig.lib.json new file mode 100644 index 00000000000..4befa7f0990 --- /dev/null +++ b/libs/payments/email/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/payments/email/tsconfig.spec.json b/libs/payments/email/tsconfig.spec.json new file mode 100644 index 00000000000..69a251f328c --- /dev/null +++ b/libs/payments/email/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/libs/shared/l10n/src/dom.ts b/libs/shared/l10n/src/dom.ts new file mode 100644 index 00000000000..d365675a8e9 --- /dev/null +++ b/libs/shared/l10n/src/dom.ts @@ -0,0 +1,8 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ +export * from './lib/localizer/localizer.server.bindings'; +export * from './lib/localizer/localizer.provider'; +export * from './lib/localizer/localizer.dom'; +export * from './lib/localizer/localizer.server'; +export * from './lib/l10n.utils'; diff --git a/libs/shared/l10n/src/lib/localizer/localizer.client.spec.ts b/libs/shared/l10n/src/lib/localizer/localizer.client.spec.ts index 56a40493ca6..5627dc5f71b 100644 --- a/libs/shared/l10n/src/lib/localizer/localizer.client.spec.ts +++ b/libs/shared/l10n/src/lib/localizer/localizer.client.spec.ts @@ -41,9 +41,8 @@ describe('LocalizerClient', () => { describe('setupReactLocalization', () => { it('should successfully create instance of ReactLocalization', async () => { const acceptLanguage = 'en,fr'; - const { l10n, selectedLocale } = await localizer.setupReactLocalization( - acceptLanguage - ); + const { l10n, selectedLocale } = + await localizer.setupReactLocalization(acceptLanguage); expect(selectedLocale).toBe('en'); // Check that bundles exist for supported locales @@ -62,4 +61,10 @@ describe('LocalizerClient', () => { expect(reportError).toHaveBeenCalled(); }); }); + + describe('setupDomLocalization', () => { + it('should have tests', async () => { + expect(false).toBe(true); + }); + }); }); diff --git a/libs/shared/l10n/src/lib/localizer/localizer.dom.factory.ts b/libs/shared/l10n/src/lib/localizer/localizer.dom.factory.ts new file mode 100644 index 00000000000..717c5e7baba --- /dev/null +++ b/libs/shared/l10n/src/lib/localizer/localizer.dom.factory.ts @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 'server-only'; +import { Injectable } from '@nestjs/common'; +import { LocalizerBase } from './localizer.base'; +import type { ILocalizerBindings } from './localizer.interfaces'; +import supportedLanguages from '../supported-languages.json'; +import { parseAcceptLanguage } from '../l10n.utils'; +import { LocalizerDom } from './localizer.dom'; + +@Injectable() +export class LocalizerDomFactory extends LocalizerBase { + private fetchedMessages: Record = {}; + constructor(bindings: ILocalizerBindings) { + super(bindings); + } + + async init() { + await this.fetchFluentMessages(); + } + + private async fetchFluentMessages() { + this.fetchedMessages = await this.fetchMessages(supportedLanguages); + } + + createLocalizerDom( + acceptLanguages?: string | null, + selectedLocale?: string + ): LocalizerDom { + const bundleGenerator = this.createBundleGenerator(this.fetchedMessages); + const currentLocales = parseAcceptLanguage( + acceptLanguages, + undefined, + selectedLocale + ); + return new LocalizerDom(currentLocales, bundleGenerator); + } +} diff --git a/libs/shared/l10n/src/lib/localizer/localizer.dom.ts b/libs/shared/l10n/src/lib/localizer/localizer.dom.ts new file mode 100644 index 00000000000..0668425bb65 --- /dev/null +++ b/libs/shared/l10n/src/lib/localizer/localizer.dom.ts @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { DOMLocalization } from '@fluent/dom'; +import { FluentBundle } from '@fluent/bundle'; +import * as Sentry from '@sentry/nestjs'; + +export class LocalizerDom { + private l10n: DOMLocalization; + constructor( + resourceIds: string[], + generateBundles: (locales: string[]) => Iterable + ) { + this.l10n = new DOMLocalization(resourceIds, generateBundles); + } + + connectRoot(root: HTMLElement) { + this.l10n.connectRoot(root); + } + + translateRoots() { + return this.l10n.translateRoots(); + } + + async formatValue(id: string, args?: Record, fallback?: string) { + try { + return (await this.l10n.formatValue(id, args)) ?? fallback; + } catch (e) { + Sentry.captureException(e); + return fallback || id; + } + } +} diff --git a/libs/shared/l10n/src/lib/localizer/localizer.server.ts b/libs/shared/l10n/src/lib/localizer/localizer.server.ts new file mode 100644 index 00000000000..52cf78010b0 --- /dev/null +++ b/libs/shared/l10n/src/lib/localizer/localizer.server.ts @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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 { LocalizerBase } from './localizer.base'; +import { ILocalizerBindings } from './localizer.interfaces'; +import { determineLocale, parseAcceptLanguage } from '../l10n.utils'; +import { LocalizerDom } from './localizer.dom'; + +export class LocalizerServer extends LocalizerBase { + constructor(bindings: ILocalizerBindings) { + super(bindings); + } + + async setupDomLocalization(acceptLanguage: string) { + const currentLocales = parseAcceptLanguage(acceptLanguage); + const selectedLocale = determineLocale(acceptLanguage); + const messages = await this.fetchMessages(currentLocales); + const generateBundles = this.createBundleGenerator(messages); + const l10n = new LocalizerDom(currentLocales, generateBundles); + return { l10n, selectedLocale }; + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index e60f3290439..d2f482dc1d0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,7 @@ "@fxa/payments/currency": ["libs/payments/currency/src/index.ts"], "@fxa/payments/customer": ["libs/payments/customer/src/index.ts"], "@fxa/payments/eligibility": ["libs/payments/eligibility/src/index.ts"], + "@fxa/payments/email": ["libs/payments/email/src/index.ts"], "@fxa/payments/events": ["libs/payments/events/src/index.ts"], "@fxa/payments/iap": ["libs/payments/iap/src/index.ts"], "@fxa/payments/legacy": ["libs/payments/legacy/src/index.ts"], @@ -79,6 +80,7 @@ "@fxa/shared/l10n": ["libs/shared/l10n/src/index.ts"], "@fxa/shared/l10n/client": ["libs/shared/l10n/src/client.ts"], "@fxa/shared/l10n/server": ["libs/shared/l10n/src/server.ts"], + "@fxa/shared/l10n/dom": ["libs/shared/l10n/src/dom.ts"], "@fxa/shared/log": ["libs/shared/log/src/index.ts"], "@fxa/shared/metrics/glean": ["libs/shared/metrics/glean/src/index.ts"], "@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"],