taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 1ae203ea5bdb95c1d2acd07812627d60a0c560c2
parent db8bee9b9a0ed12d5a8121657a6fcb1540ec5b4a
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Fri, 19 Dec 2025 11:58:38 -0300

fix #10790

Diffstat:
Mpackages/merchant-backend-ui/README.md | 13+++++++++----
Mpackages/merchant-backend-ui/build.mjs | 25++++++++++++++-----------
Mpackages/merchant-backend-ui/package.json | 7+++++++
Apackages/merchant-backend-ui/src/components/Application.tsx | 42++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/context/translations.ts | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/de.po | 39+++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/en.po | 38++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/es.po | 39+++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/poheader | 27+++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/strings-prelude | 19+++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/strings.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/taler-merchant-backend-ui.pot | 37+++++++++++++++++++++++++++++++++++++
Apackages/merchant-backend-ui/src/i18n/taler-merchant-backoffice.pot | 28++++++++++++++++++++++++++++
Mpackages/merchant-backend-ui/src/pages/OfferRefund.tsx | 31+++++++++++++++++++++++++------
Mpackages/merchant-backend-ui/src/pages/RequestPayment.tsx | 31+++++++++++++++++++++++++------
Mpackages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx | 118++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/merchant-backend-ui/src/render-examples.ts | 7++++---
Apackages/merchant-backend-ui/src/utils/i18n.ts | 181+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpnpm-lock.yaml | 3+++
19 files changed, 855 insertions(+), 70 deletions(-)

diff --git a/packages/merchant-backend-ui/README.md b/packages/merchant-backend-ui/README.md @@ -11,10 +11,15 @@ This project generates templates for the Taler Merchant backend: These pages are provided by the merchant-backend service and will be queried for browsers that either may or may not have enabled JavaScript. The merchant-backend service is currently supporting a mustache library for server-side rendering. -We also want the be able to create a more interactive design given the case that browsers have JavaScript enabled, -so the pages will be rendered with all information in HTML format as well as with JavaScript. -In this scenario, we are using jsx to build the template of the page that will be rendered into the mustache template at build time. -This template can then be deployed in a merchant-backend that will complete the information before it is sent to the browser. +If the browser have JavaScript enabled will still want to use some dynamic content behavior like polling the status or re-render after timeout. + +Given this scenario we have: + +1) a build process from source to mustache template. These are html files in the `dist/pages` folder. +2) a server side render process from mustache template to HTML for the browser. +3) a client side render process that uses preact + +The process (1) is # Building diff --git a/packages/merchant-backend-ui/build.mjs b/packages/merchant-backend-ui/build.mjs @@ -45,6 +45,7 @@ const preactCompatPlugin = { }; const pages = ["OfferRefund", "RequestPayment", "ShowOrderDetails"] +const langs = ["en", "de", "es"] const entryPoints = pages.map(p => `src/pages/${p}.tsx`); let GIT_ROOT = BASE; @@ -75,11 +76,11 @@ function git_hash() { return fs.readFileSync(path.join(GIT_ROOT, ".git", rev)).toString().trim(); } } -function toCamelCaseName(name) { +function toCamelCaseName(name, lang) { return name .replace(/^[A-Z]/, letter => `${letter.toLowerCase()}`) //first letter lowercase .replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) //snake case - .concat(".en.html"); //extension + .concat(`.${lang}.html`); //extension } function templatePlugin(options) { @@ -88,13 +89,14 @@ function templatePlugin(options) { setup(build) { build.onEnd(() => { for (const pageName of options.pages) { - const css = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.css`), "utf8").toString() - const js = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.js`), "utf8").toString() - const location = path.join(build.initialOptions.outdir, toCamelCaseName(pageName)) - const render = new Function(`${js}; return page.buildTimeRendering();`)() - const html = ` + for (const langName of options.langs) { + const css = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.css`), "utf8").toString() + const js = fs.readFileSync(path.join(build.initialOptions.outdir, `${pageName}.js`), "utf8").toString() + const location = path.join(build.initialOptions.outdir, toCamelCaseName(pageName, langName)) + const render = new Function(`${js}; return page.buildTimeRendering("${langName}");`)() + const html = ` <!doctype html> - <html> + <html lang="${langName}"> <head> ${render.head} <style>${css}</style> @@ -104,10 +106,11 @@ function templatePlugin(options) { <body> ${render.body} <script>${js}</script> - <script>page.mount()</script> + <script>page.mount("${langName}")</script> </body> </html>` - fs.writeFileSync(location, html); + fs.writeFileSync(location, html); + } } }); }, @@ -156,7 +159,7 @@ export const buildConfig = { sourceMap: true, }), preactCompatPlugin, - templatePlugin({ pages }) + templatePlugin({ pages, langs }) ], }; diff --git a/packages/merchant-backend-ui/package.json b/packages/merchant-backend-ui/package.json @@ -7,6 +7,8 @@ "compile": "tsc && ./build.mjs", "render-examples": "node dist/test/render-examples.js dist/pages dist/examples", "lint-check": "eslint '{src,tests}/**/*.{js,jsx,ts,tsx}'", + "i18n:source2po": "pogen extract && pogen merge", + "i18n:po2strings": "pogen emit", "lint-fix": "eslint --fix '{src,tests}/**/*.{js,jsx,ts,tsx}'", "clean": "rm -rf dist lib tsconfig.tsbuildinfo", "serve-dist": "sirv --port ${PORT:=8080} --cors --single dist" @@ -36,6 +38,7 @@ }, "dependencies": { "date-fns": "^2.21.1", + "jed": "1.1.1", "preact": "10.11.3", "qrcode-generator": "^1.4.4" }, @@ -64,5 +67,9 @@ "ts-node": "^10.9.1", "tslib": "2.6.2", "typescript": "5.7.3" + }, + "pogen": { + "domain": "taler-merchant-backend-ui" } + } diff --git a/packages/merchant-backend-ui/src/components/Application.tsx b/packages/merchant-backend-ui/src/components/Application.tsx @@ -0,0 +1,42 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ComponentChildren, h, VNode } from "preact"; +import { TranslationProvider } from "../context/translations"; +import { strings } from "../i18n/strings"; + +interface Props { + children: ComponentChildren; + lang: string; +} +export function Application({ children, lang }: Props): VNode { + return ( + <TranslationProvider + source={strings} + lang={lang} + completeness={{ + es: strings["es"].completeness, + de: strings["de"].completeness, + }} + > + {children} + </TranslationProvider> + ); +} diff --git a/packages/merchant-backend-ui/src/context/translations.ts b/packages/merchant-backend-ui/src/context/translations.ts @@ -0,0 +1,155 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { i18n, setupI18n } from "../utils/i18n"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { Locale } from "date-fns"; +import { + es as esLocale, + enGB as enLocale, + fr as frLocale, + de as deLocale, +} from "date-fns/locale"; + +export type InternationalizationAPI = typeof i18n; + +interface Type { + lang: string; + supportedLang: { [id in keyof typeof supportedLang]: string }; + i18n: InternationalizationAPI; + dateLocale: Locale; + completeness: { [id in keyof typeof supportedLang]: number }; +} + +const supportedLang = { + es: "Espanol [es]", + en: "English [en]", + fr: "Francais [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiane [it]", +}; + +const initial: Type = { + lang: "en", + supportedLang, + i18n, + dateLocale: enLocale, + completeness: { + de: 0, + en: 0, + es: 0, + fr: 0, + it: 0, + sv: 0, + }, +}; +const Context = createContext<Type>(initial); + +interface Props { + children: ComponentChildren; + lang: string; + source: Record<string, any>; + completeness?: Record<string, number>; +} + +// Outmost UI wrapper. +export const TranslationProvider = ({ + children, + lang, + source, + completeness: completenessProp, +}: Props): VNode => { + const completeness = { + en: 100, + de: + !completenessProp || !completenessProp["de"] ? 0 : completenessProp["de"], + es: + !completenessProp || !completenessProp["es"] ? 0 : completenessProp["es"], + fr: + !completenessProp || !completenessProp["fr"] ? 0 : completenessProp["fr"], + it: + !completenessProp || !completenessProp["it"] ? 0 : completenessProp["it"], + sv: + !completenessProp || !completenessProp["sv"] ? 0 : completenessProp["sv"], + }; + + setupI18n(lang, source); + + const dateLocale = + lang === "es" + ? esLocale + : lang === "fr" + ? frLocale + : lang === "de" + ? deLocale + : enLocale; + + return h(Context.Provider, { + value: { + lang, + supportedLang, + i18n, + dateLocale, + completeness, + }, + children, + }); +}; + +export const useTranslationContext = (): Type => useContext(Context); + +const MIN_LANG_COVERAGE_THRESHOLD = 90; +/** + * choose the best from the browser config based on the completeness + * on the translation + */ +function getBrowserLang( + completeness: Record<string, number>, +): string | undefined { + if (typeof window === "undefined") return undefined; + + if (window.navigator.language) { + if ( + completeness[window.navigator.language] >= MIN_LANG_COVERAGE_THRESHOLD + ) { + return window.navigator.language; + } + } + if (window.navigator.languages) { + const match = Object.entries(completeness) + .filter(([code, value]) => { + if (value < MIN_LANG_COVERAGE_THRESHOLD) return false; //do not consider langs below 90% + return ( + window.navigator.languages.findIndex((l) => l.startsWith(code)) !== -1 + ); + }) + .map(([code, value]) => ({ code, value })); + + if (match.length > 0) { + let max = match[0]; + match.forEach((v) => { + if (v.value > max.value) { + max = v; + } + }); + return max.code; + } + } + + return undefined; +} diff --git a/packages/merchant-backend-ui/src/i18n/de.po b/packages/merchant-backend-ui/src/i18n/de.po @@ -0,0 +1,39 @@ +# This file is part of TALER +# (C) 2016 GNUnet e.V. +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +msgid "" +msgstr "" +"Project-Id-Version: Taler Wallet\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"POT-Creation-Date: 2016-11-23 00:00+0100\n" +"PO-Revision-Date: 2025-12-16 21:22+0000\n" +"Last-Translator: Stefan Kügel <stefan.kuegel@taler.net>\n" +"Language-Team: German <https://weblate.gnunet.org/projects/gnu-taler/" +"merchant-backoffice/de/>\n" +"Language: de\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.13.2\n" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:68 +#, c-format +msgid "Status of your order for" +msgstr "" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:163 +#, c-format +msgid "Details of order" +msgstr "" diff --git a/packages/merchant-backend-ui/src/i18n/en.po b/packages/merchant-backend-ui/src/i18n/en.po @@ -0,0 +1,38 @@ +# This file is part of TALER +# (C) 2016 GNUnet e.V. +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Taler Wallet\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"POT-Creation-Date: 2016-11-23 00:00+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:68 +#, c-format +msgid "Status of your order for" +msgstr "" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:163 +#, c-format +msgid "Details of order" +msgstr "" diff --git a/packages/merchant-backend-ui/src/i18n/es.po b/packages/merchant-backend-ui/src/i18n/es.po @@ -0,0 +1,39 @@ +# This file is part of TALER +# (C) 2016 GNUnet e.V. +# +# TALER is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. +# +# TALER is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> +# +msgid "" +msgstr "" +"Project-Id-Version: Taler Wallet\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"POT-Creation-Date: 2016-11-23 00:00+0100\n" +"PO-Revision-Date: 2025-12-11 17:06+0000\n" +"Last-Translator: Stefan Kügel <stefan.kuegel@taler.net>\n" +"Language-Team: Spanish <https://weblate.gnunet.org/projects/gnu-taler/" +"merchant-backoffice/es/>\n" +"Language: es\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.13.2\n" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:68 +#, c-format +msgid "Status of your order for" +msgstr "Estado de la orden para" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:163 +#, c-format +msgid "Details of order" +msgstr "Detalles de la orden" diff --git a/packages/merchant-backend-ui/src/i18n/poheader b/packages/merchant-backend-ui/src/i18n/poheader @@ -0,0 +1,27 @@ +# This file is part of GNU Taler +# (C) 2021-2023 Taler Systems S.A. + +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. + +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Taler Backend UI\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" diff --git a/packages/merchant-backend-ui/src/i18n/strings-prelude b/packages/merchant-backend-ui/src/i18n/strings-prelude @@ -0,0 +1,19 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/*eslint quote-props: ["error", "consistent"]*/ +export const strings: {[s: string]: any} = {}; + diff --git a/packages/merchant-backend-ui/src/i18n/strings.ts b/packages/merchant-backend-ui/src/i18n/strings.ts @@ -0,0 +1,85 @@ +/* + This file is part of GNU Taler + (C) 2021-2023 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/*eslint quote-props: ["error", "consistent"]*/ +export const strings: {[s: string]: any} = {}; + +strings['es'] = { + "locale_data": { + "messages": { + "": { + "domain": "messages", + "plural_forms": "nplurals=2; plural=n != 1;", + "lang": "es" + }, + "Status of your order for": [ + "Estado de la orden para" + ], + "Details of order": [ + "Detalles de la orden" + ] + } + }, + "domain": "messages", + "plural_forms": "nplurals=2; plural=n != 1;", + "lang": "es", + "completeness": 100 +}; + +strings['en'] = { + "locale_data": { + "messages": { + "": { + "domain": "messages", + "plural_forms": "nplurals=2; plural=(n != 1);", + "lang": "en" + }, + "Status of your order for": [ + "" + ], + "Details of order": [ + "" + ] + } + }, + "domain": "messages", + "plural_forms": "nplurals=2; plural=(n != 1);", + "lang": "en", + "completeness": 100 +}; + +strings['de'] = { + "locale_data": { + "messages": { + "": { + "domain": "messages", + "plural_forms": "nplurals=2; plural=n != 1;", + "lang": "de" + }, + "Status of your order for": [ + "" + ], + "Details of order": [ + "" + ] + } + }, + "domain": "messages", + "plural_forms": "nplurals=2; plural=n != 1;", + "lang": "de", + "completeness": 0 +}; + diff --git a/packages/merchant-backend-ui/src/i18n/taler-merchant-backend-ui.pot b/packages/merchant-backend-ui/src/i18n/taler-merchant-backend-ui.pot @@ -0,0 +1,37 @@ +# This file is part of GNU Taler +# (C) 2021-2023 Taler Systems S.A. + +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. + +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Taler Backend UI\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:68 +#, c-format +msgid "Status of your order for" +msgstr "" + +#: packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx:163 +#, c-format +msgid "Details of order" +msgstr "" + diff --git a/packages/merchant-backend-ui/src/i18n/taler-merchant-backoffice.pot b/packages/merchant-backend-ui/src/i18n/taler-merchant-backoffice.pot @@ -0,0 +1,28 @@ +# This file is part of GNU Taler +# (C) 2021-2023 Taler Systems S.A. + +# GNU Taler is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 3, or (at your option) any later version. + +# GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License along with +# GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Taler Backend UI\n" +"Report-Msgid-Bugs-To: taler@gnu.org\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#. screenid: 18 diff --git a/packages/merchant-backend-ui/src/pages/OfferRefund.tsx b/packages/merchant-backend-ui/src/pages/OfferRefund.tsx @@ -26,6 +26,7 @@ import { QR } from "../components/QR"; import "../css/pure-min.css"; import "../css/style.css"; import { Page, QRPlaceholder, WalletLink } from "../styled"; +import { Application } from "../components/Application"; /** * This page creates a refund offer QR code @@ -143,12 +144,17 @@ export function OfferRefund({ ); } -export function mount(): void { +export function mount(lang: string): void { try { const fromLocation = new URL(window.location.href).searchParams; const os = fromLocation.get("order_summary") || undefined; if (os) { - render(<Head order_summary={os} />, document.head); + render( + <Application lang={lang}> + <Head order_summary={os} /> + </Application>, + document.head, + ); } const uri = fromLocation.get("refund_uri") || undefined; @@ -156,7 +162,9 @@ export function mount(): void { const qr_code = uri ? renderToString(<QR text={uri} />) : undefined; render( - <OfferRefund refundURI={uri} order_status_url={osu} qr_code={qr_code} />, + <Application lang={lang}> + <OfferRefund refundURI={uri} order_status_url={osu} qr_code={qr_code} /> + </Application>, document.body, ); } catch (e) { @@ -167,9 +175,20 @@ export function mount(): void { } } -export function buildTimeRendering(): { head: string; body: string } { +export function buildTimeRendering(lang: string): { + head: string; + body: string; +} { return { - head: renderToString(<Head />), - body: renderToString(<OfferRefund />), + head: renderToString( + <Application lang={lang}> + <Head /> + </Application>, + ), + body: renderToString( + <Application lang={lang}> + <OfferRefund /> + </Application>, + ), }; } diff --git a/packages/merchant-backend-ui/src/pages/RequestPayment.tsx b/packages/merchant-backend-ui/src/pages/RequestPayment.tsx @@ -26,6 +26,7 @@ import { QR } from "../components/QR"; import "../css/pure-min.css"; import "../css/style.css"; import { Page, QRPlaceholder, WalletLink } from "../styled"; +import { Application } from "../components/Application"; /** * This page creates a payment request QR code @@ -171,12 +172,17 @@ export function RequestPayment({ ); } -export function mount(): void { +export function mount(lang: string): void { try { const fromLocation = new URL(window.location.href).searchParams; const os = fromLocation.get("order_summary") || undefined; if (os) { - render(<Head order_summary={os} />, document.head); + render( + <Application lang={lang}> + <Head order_summary={os} /> + </Application>, + document.head, + ); } const uri = fromLocation.get("pay_uri") || undefined; @@ -184,7 +190,9 @@ export function mount(): void { const qr_code = uri ? renderToString(<QR text={uri} />) : undefined; render( - <RequestPayment payURI={uri} order_status_url={osu} qr_code={qr_code} />, + <Application lang={lang}> + <RequestPayment payURI={uri} order_status_url={osu} qr_code={qr_code} /> + </Application>, document.body, ); } catch (e) { @@ -195,9 +203,20 @@ export function mount(): void { } } -export function buildTimeRendering(): { head: string; body: string } { +export function buildTimeRendering(lang: string): { + head: string; + body: string; +} { return { - head: renderToString(<Head />), - body: renderToString(<RequestPayment />), + head: renderToString( + <Application lang={lang}> + <Head /> + </Application>, + ), + body: renderToString( + <Application lang={lang}> + <RequestPayment /> + </Application>, + ), }; } diff --git a/packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx b/packages/merchant-backend-ui/src/pages/ShowOrderDetails.tsx @@ -25,9 +25,11 @@ import { render as renderToString } from "preact-render-to-string"; import { Footer } from "../components/Footer"; import "../css/pure-min.css"; import "../css/style.css"; -import { MerchantBackend } from "../declaration"; +import type { MerchantBackend } from "../declaration"; import { Page, InfoBox, TableExpanded, TableSimple } from "../styled"; import { TIME_DATE_FORMAT } from "../utils"; +import { Application } from "../components/Application"; +import { useTranslationContext } from "../context/translations"; /** * This page creates a payment request QR code @@ -53,6 +55,7 @@ export interface Props { } function Head({ order_summary }: { order_summary?: string }): VNode { + const { i18n } = useTranslationContext(); return ( <Fragment> <meta charSet="UTF-8" /> @@ -62,7 +65,7 @@ function Head({ order_summary }: { order_summary?: string }): VNode { <meta http-equiv="refresh" content="1" /> </noscript> <title> - Status of your order for{" "} + <i18n.Translate>Status of your order for </i18n.Translate> {order_summary ? order_summary : `{{ order_summary }}`} </title> <script>{` @@ -138,6 +141,7 @@ export function ShowOrderDetails({ contract_terms, btr, }: Props): VNode { + const { i18n } = useTranslationContext(); const productList = btr ? [{} as MerchantBackend.Product] : contract_terms?.products || []; @@ -156,7 +160,7 @@ export function ShowOrderDetails({ <Page> <header> <h1> - Details of order{" "} + <i18n.Translate>Details of order </i18n.Translate> {contract_terms?.order_id || `{{ contract_terms.order_id }}`} </h1> </header> @@ -177,7 +181,11 @@ export function ShowOrderDetails({ {(btr || contract_terms?.fulfillment_message) && ( <section> <InfoBox> - <b>{contract_terms?.fulfillment_message || `{{ contract_terms.fulfillment_message }}`}</b>. + <b> + {contract_terms?.fulfillment_message || + `{{ contract_terms.fulfillment_message }}`} + </b> + . </InfoBox> </section> )} @@ -189,7 +197,17 @@ export function ShowOrderDetails({ <dd>{contract_terms?.summary || `{{ contract_terms.summary }}`}</dd> {btr && `{{#contract_terms.fulfillment_url}}`} <dt>Fulfillment URL:</dt> - <dd><a href={contract_terms?.fulfillment_url || `{{ contract_terms.fulfillment_url }}`}>{contract_terms?.fulfillment_url || `{{ contract_terms.fulfillment_url }}`}</a></dd> + <dd> + <a + href={ + contract_terms?.fulfillment_url || + `{{ contract_terms.fulfillment_url }}` + } + > + {contract_terms?.fulfillment_url || + `{{ contract_terms.fulfillment_url }}`} + </a> + </dd> {btr && `{{/contract_terms.fulfillment_url}}`} <dt>Amount paid:</dt> <dd>{contract_terms?.amount || `{{ contract_terms.amount }}`}</dd> @@ -198,9 +216,9 @@ export function ShowOrderDetails({ {contract_terms?.timestamp ? contract_terms?.timestamp.t_s != "never" ? format( - contract_terms?.timestamp.t_s * 1000, - TIME_DATE_FORMAT, - ) + contract_terms?.timestamp.t_s * 1000, + TIME_DATE_FORMAT, + ) : "never" : `{{ contract_terms.timestamp_str }}`}{" "} </dd> @@ -257,10 +275,7 @@ export function ShowOrderDetails({ <dd> {p.delivery_date ? p.delivery_date.t_s != "never" - ? format( - p.delivery_date.t_s, - TIME_DATE_FORMAT, - ) + ? format(p.delivery_date.t_s, TIME_DATE_FORMAT) : "never" : `{{ delivery_date_str }}`}{" "} </dd> @@ -308,9 +323,9 @@ export function ShowOrderDetails({ {contract_terms?.delivery_date ? contract_terms?.delivery_date.t_s != "never" ? format( - contract_terms?.delivery_date.t_s, - TIME_DATE_FORMAT, - ) + contract_terms?.delivery_date.t_s, + TIME_DATE_FORMAT, + ) : "never" : `{{ contract_terms.delivery_date_str }}`}{" "} </dd> @@ -344,18 +359,16 @@ export function ShowOrderDetails({ {contract_terms?.wire_transfer_deadline ? contract_terms?.wire_transfer_deadline.t_s != "never" ? format( - contract_terms?.wire_transfer_deadline.t_s * 1000, - TIME_DATE_FORMAT, - ) + contract_terms?.wire_transfer_deadline.t_s * 1000, + TIME_DATE_FORMAT, + ) : "never" : `{{ contract_terms.wire_transfer_deadline_str }}`}{" "} </dd> {btr && `{{` + `/contract_terms.wire_transfer_deadline_str}}`} {btr && `{{` + `^contract_terms.wire_transfer_deadline_str}}`} - <dd> - Wire transfer settled. - </dd> + <dd>Wire transfer settled.</dd> {btr && `{{` + `/contract_terms.wire_transfer_deadline_str}}`} {btr && `{{` + `#contract_terms.max_fee}}`} @@ -390,9 +403,9 @@ export function ShowOrderDetails({ {contract_terms?.refund_deadline ? contract_terms?.refund_deadline.t_s != "never" ? format( - contract_terms?.refund_deadline.t_s * 1000, - TIME_DATE_FORMAT, - ) + contract_terms?.refund_deadline.t_s * 1000, + TIME_DATE_FORMAT, + ) : "never" : `{{ contract_terms.refund_deadline_str }}`}{" "} </dd> @@ -405,11 +418,11 @@ export function ShowOrderDetails({ {contract_terms?.auto_refund ? contract_terms?.auto_refund.d_us != "forever" ? formatDuration( - intervalToDuration({ - start: 0, - end: contract_terms?.auto_refund.d_us, - }), - ) + intervalToDuration({ + start: 0, + end: contract_terms?.auto_refund.d_us, + }), + ) : "forever" : `{{ contract_terms.auto_refund_str }}`}{" "} </dd> @@ -526,12 +539,20 @@ export function ShowOrderDetails({ ); } -export function mount(): void { +/** + * + */ +export function mount(lang: string): void { try { const fromLocation = new URL(window.location.href).searchParams; const os = fromLocation.get("order_summary") || undefined; if (os) { - render(<Head order_summary={os} />, document.head); + render( + <Application lang={lang}> + <Head order_summary={os} /> + </Application>, + document.head, + ); } const ra = fromLocation.get("refund_amount") || undefined; @@ -540,14 +561,16 @@ export function mount(): void { let contractTerms: MerchantBackend.ContractTerms | undefined; try { contractTerms = JSON.parse((window as any).contractTermsStr); - } catch { } + } catch {} render( - <ShowOrderDetails - contract_terms={contractTerms} - order_summary={os} - refund_amount={ra} - />, + <Application lang={lang}> + <ShowOrderDetails + contract_terms={contractTerms} + order_summary={os} + refund_amount={ra} + /> + </Application>, document.body, ); } catch (e) { @@ -558,9 +581,24 @@ export function mount(): void { } } -export function buildTimeRendering(): { head: string; body: string } { +/** + * Build the mustache template at compile time. + * @returns + */ +export function buildTimeRendering(lang: string): { + head: string; + body: string; +} { return { - head: renderToString(<Head />), - body: renderToString(<ShowOrderDetails btr />), + head: renderToString( + <Application lang={lang}> + <Head /> + </Application>, + ), + body: renderToString( + <Application lang={lang}> + <ShowOrderDetails btr /> + </Application>, + ), }; } diff --git a/packages/merchant-backend-ui/src/render-examples.ts b/packages/merchant-backend-ui/src/render-examples.ts @@ -51,13 +51,14 @@ function fromCamelCaseName(name: string) { */ const templateFiles = fs.readdirSync(templateDirectory).filter((f) => /.html/.test(f)); const exampleByTemplate: Record<string, any> = { - "show_order_details.en.html": ShowOrderDetailsExamples + "show_order_details.en.html": ShowOrderDetailsExamples, + "show_order_details.es.html": ShowOrderDetailsExamples, } templateFiles.forEach((templateFile) => { const html = fs.readFileSync(`${templateDirectory}/${templateFile}`, "utf8"); - const templateFileWithoutExt = templateFile.replace(".en.html", ""); + const [templateFileWithoutExt, lang, extension] = templateFile.split("."); // const exampleFileName = `src/pages/${fromCamelCaseName(testName)}.examples`; // if (!fs.existsSync(`./${exampleFileName}.ts`)) { // console.log(`- skipping ${testName}: no examples found`); @@ -105,7 +106,7 @@ templateFiles.forEach((templateFile) => { const output = mustache.render(html, example); fs.writeFileSync( - `${destDirectory}/${templateFileWithoutExt}.${exampleName}.html`, + `${destDirectory}/${templateFileWithoutExt}.${lang}.${exampleName}.html`, output, ); }); diff --git a/packages/merchant-backend-ui/src/utils/i18n.ts b/packages/merchant-backend-ui/src/utils/i18n.ts @@ -0,0 +1,181 @@ +// @ts-ignore: no type decl for this library +import * as jedLib from "jed"; + +export let jed: any = undefined; + +/** + * Set up jed library for internationalization, + * based on browser language settings. + */ +export function setupI18n(lang: string, strings: { [s: string]: any }): void { + lang = lang.replace("_", "-"); + + if (!strings[lang]) { + strings[lang] = {}; + } + jed = new jedLib.Jed(strings[lang]); +} + +/** + * Use different translations for testing. Should not be used outside + * of test cases. + */ +export function internalSetStrings(langStrings: any): void { + jed = new jedLib.Jed(langStrings); +} + +declare const __translated: unique symbol; +export type TranslatedString = string & { [__translated]: true }; +export type ToTranslateString = string & { [__translated]: true }; + +/** + * Convert template strings to a msgid + */ +function toI18nString(stringSeq: ReadonlyArray<string>): TranslatedString { + let s = ""; + for (let i = 0; i < stringSeq.length; i++) { + s += stringSeq[i]; + if (i < stringSeq.length - 1) { + s += `%${i + 1}$s`; + } + } + return s as TranslatedString; +} + +/** + * Internationalize a string template with arbitrary serialized values. + */ +export function singular( + stringSeq: TemplateStringsArray, + ...values: any[] +): TranslatedString { + const s = toI18nString(stringSeq); + // jed throws a Error when key is empty + if (!s) return "" as TranslatedString; + const tr = jed + .translate(s) + .ifPlural(1, s) + .fetch(...values); + return tr; +} + +function withContext(ctx: string): typeof singular { + return function (t: TemplateStringsArray, ...v: any[]): TranslatedString { + const s = toI18nString(t); + const tr = jed + .translate(s) + .withContext(ctx) + .ifPlural(1, s) + .fetch(...v); + return tr; + }; +} + +/** + * Internationalize a string template without serializing + */ +export function translate( + stringSeq: TemplateStringsArray, + ...values: any[] +): TranslatedString[] { + const s = toI18nString(stringSeq); + if (!s) return []; + const translation: TranslatedString = jed.ngettext(s, s, 1); + return replacePlaceholderWithValues(translation, values); +} + +/** + * Internationalize a string template without serializing + */ +export function Translate({ + children, + debug, + context: ctx, +}: { + children: any; + debug?: boolean; + context?: string; +}): any { + const c = [].concat(children); + const s = stringifyArray(c); + if (!s) return []; + const translation: TranslatedString = ctx + ? jed.npgettext(ctx, s, s, 1) + : jed.ngettext(s, s, 1); + if (debug) { + console.log("looking for ", s, "got", translation); + } + return replacePlaceholderWithValues(translation, c); +} + +/** + * Get an internationalized string (based on the globally set, current language) + * from a JSON object. Fall back to the default language of the JSON object + * if no match exists. + */ +export function getJsonI18n<K extends string>( + obj: Record<K, string>, + key: K, +): string { + return obj[key]; +} + +export function getTranslatedArray(array: Array<any>) { + const s = stringifyArray(array); + const translation: TranslatedString = jed.ngettext(s, s, 1); + return replacePlaceholderWithValues(translation, array); +} + +function replacePlaceholderWithValues( + translation: TranslatedString, + childArray: Array<any>, +): Array<any> { + const tr = translation.split(/%(\d+)\$s/); + // const childArray = toChildArray(children); + // Merge consecutive string children. + const placeholderChildren = []; + for (let i = 0; i < childArray.length; i++) { + const x = childArray[i]; + if (x === undefined) { + continue; + } else if (typeof x === "string") { + continue; + } else { + placeholderChildren.push(x); + } + } + const result = []; + for (let i = 0; i < tr.length; i++) { + if (i % 2 == 0) { + // Text + result.push(tr[i]); + } else { + const childIdx = Number.parseInt(tr[i]) - 1; + result.push(placeholderChildren[childIdx]); + } + } + return result; +} + +function stringifyArray(children: Array<any>): string { + let n = 1; + const ss = children.map((c) => { + if (typeof c === "string") { + return c; + } + return `%${n++}$s`; + }); + const s = ss.join("").replace(/ +/g, " ").trim(); + return s; +} + +export type InternationalizationAPI = typeof i18n; +export type Translator = (i18n: InternationalizationAPI) => TranslatedString; + +export const i18n = { + str: singular, + ctx: withContext, + singular, + Translate, + translate, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -618,6 +618,9 @@ importers: date-fns: specifier: ^2.21.1 version: 2.29.3 + jed: + specifier: 1.1.1 + version: 1.1.1 preact: specifier: 10.11.3 version: 10.11.3