taler-typescript-core

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

commit 19a751a8b1a5b0976d9d081fbdc19e2f5cbead6a
parent e8b2a9f6f93f2ace957cf4f2bdf0b06e30601a0b
Author: Sebastian <sebasjm@taler-systems.com>
Date:   Thu,  9 Apr 2026 15:11:47 -0300

fix #11343

Diffstat:
Dpackages/merchant-backoffice-ui/src/components/exception/QR.tsx | 213-------------------------------------------------------------------------------
Mpackages/merchant-backoffice-ui/src/components/modal/index.tsx | 9+++++----
Mpackages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx | 2+-
Mpackages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx | 3+--
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx | 3+--
Mpackages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx | 349+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/taler-wallet-webextension/src/components/PaymentButtons.tsx | 12+++++++-----
Dpackages/taler-wallet-webextension/src/components/QR.stories.tsx | 31-------------------------------
Dpackages/taler-wallet-webextension/src/components/QR.tsx | 49-------------------------------------------------
Dpackages/taler-wallet-webextension/src/components/ShowQRsForPaytoPopup.tsx | 94-------------------------------------------------------------------------------
Mpackages/taler-wallet-webextension/src/components/index.stories.tsx | 1-
Mpackages/taler-wallet-webextension/src/cta/Withdraw/views.tsx | 12++++++------
Mpackages/taler-wallet-webextension/src/wallet/Transaction.tsx | 58+++++++++++++++++++++++++++++++++++++++++-----------------
Apackages/taler-wallet-webextension/static/font/animations.css | 15+++++++++++++++
Mpackages/taler-wallet-webextension/static/popup.html | 1+
Mpackages/taler-wallet-webextension/static/wallet.html | 1+
Mpackages/taler-wallet-webextension/tsconfig.json | 3+++
Mpackages/web-util/package.json | 1+
Apackages/web-util/src/components/QR.tsx | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/web-util/src/components/index.ts | 1+
Mpackages/web-util/tsconfig.json | 3+++
Mpnpm-lock.yaml | 3+++
23 files changed, 527 insertions(+), 578 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/components/exception/QR.tsx b/packages/merchant-backoffice-ui/src/components/exception/QR.tsx @@ -1,213 +0,0 @@ -/* - This file is part of GNU Taler - (C) 2021-2024 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 logo from "@assets/svg/logo/qr-logo.svg"; -import chFlag from "@assets/svg/swiss-qr-flag.svg"; -import { TalerUri, TalerUris, TranslatedString } from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useEffect, useRef } from "preact/hooks"; -import qrcode from "qrcode-generator"; - -function generate_qr( - text: string, - params: { - typeNumber?: TypeNumber; - errorCorrectionLevel?: ErrorCorrectionLevel; - } = {}, -) { - const qr = qrcode( - params.typeNumber ?? 0, - (params.errorCorrectionLevel = "H"), - ); - qr.addData(text, "Byte"); - qr.make(); - const image = qr.createSvgTag({ - scalable: true, - margin: 0, - }); - return `data:image/svg+xml,${encodeURIComponent(image)}`; -} - -export function QR({ - children, - text, - style, - errorCorrectionLevel = "H", - typeNumber = 0, - ...rest -}: { - text: string; - typeNumber?: TypeNumber; - errorCorrectionLevel?: ErrorCorrectionLevel; - children?: VNode<HTMLImageElement>; -} & h.JSX.HTMLAttributes<HTMLDivElement>): VNode { - const divRef = useRef<HTMLDivElement>(null); - useEffect(() => { - const qr = qrcode(typeNumber, errorCorrectionLevel); - qr.addData(text, "Byte"); - qr.make(); - if (divRef.current) { - const image = qr.createSvgTag({ - scalable: true, - margin: 0, - }); - const imageURL = `data:image/svg+xml,${encodeURIComponent(image)}`; - divRef.current.innerHTML = `<img src=${JSON.stringify( - imageURL, - )} alt=${JSON.stringify( - `QR Code containing the data ${text - .replace(/&/g, "&amp;") - .replace(/</g, "&lt;") - .replace(/>/g, "&gt;") - .replace(/"/g, "&quot;") - .replace(/'/g, "&#039;")}`, - )} />`; - } - }); - - if (children) { - return ( - <div {...rest} style={{ ...(style as any), position: "relative" }}> - <div ref={divRef} /> - <div - style={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - alignContent: "center", - }} - > - {children} - </div> - </div> - ); - } - return <div {...rest} ref={divRef} />; -} - -export function QR_Taler({ uri }: { uri: TalerUri }): VNode { - const stringUri = TalerUris.toString(uri); - return ( - <div - style={{ - width: "100%", - height: "100%", - maxWidth: 400, - margin: "auto", - padding: 10, // size of the animated border - borderRadius: 20, - background: `conic-gradient( - from var(--angle), - #0042B3 0deg, - #F1F1F4 20deg, - #F1F1F4 150deg, - #F1F1F4 160deg, - #0042B3 180deg, - #F1F1F4 200deg, - #F1F1F4 330deg, - #F1F1F4 340deg, - #0042B3) padding-box`, - animation: "rotate 10s linear infinite", - position: "relative", - }} - > - <div - style={{ - padding: 10, // separate the qr from the animated border - borderRadius: 20, // match the radius from the parent - backgroundColor: "white", // hide the background - }} - > - <img style={{ margin: 5 }} src={generate_qr(stringUri)} /> - </div> - <div - style={{ - position: "absolute", - top: "50%", - left: "50%", - transform: "translate(-50%, -50%)", - alignContent: "center", - display: "flex", - backgroundColor: "transparent", // section 19.90.4.3 - }} - > - <img src={logo} style={{ width: 100, height: 50 }} /> - </div> - </div> - ); -} - -export function QR_TOTP({ otpAuthURI }: { otpAuthURI: string }): VNode { - return ( - <QR - style={{ width: "90%", maxWidth: 400, margin: "auto", padding: 10 }} - text={otpAuthURI} - > - <div - style={{ - display: "flex", - border: "1px solid black", - backgroundColor: "white", - fontWeight: "bold", - padding: 5, - }} - > - T-OTP - </div> - </QR> - ); -} - -/** - * Based on the definition of Swiss Implementation Guidelines - * for the QR-bill - * @param param0 - * @returns - */ -export function QR_SwissBank({ text }: { text: string }): VNode { - return ( - <QR - style={{ width: "90%", maxWidth: 300, margin: "auto", padding: 10 }} - text={text} - typeNumber={0} // section 6.2 - errorCorrectionLevel="M" // section 6.1 - > - <div - style={{ - display: "flex", - backgroundColor: "white", - }} - > - <img src={chFlag} style={{ width: 50, height: 50 }} /> - </div> - </QR> - ); -} - -export function QR_Bank({ - text, - label, -}: { - text: string; - label: TranslatedString; -}): VNode { - return ( - <QR style={{ width: "90%", maxWidth: 300, margin: "auto" }} text={text}> - <div style={{ fontWeight: "bold" }}>{label}</div> - </QR> - ); -} diff --git a/packages/merchant-backoffice-ui/src/components/modal/index.tsx b/packages/merchant-backoffice-ui/src/components/modal/index.tsx @@ -29,17 +29,18 @@ import { } from "@gnu-taler/taler-util"; import { ButtonBetterBulma, + QR_Bank, + QR_SwissBank, SafeHandlerTemplate, useCommonPreferences, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; +import { useSessionContext } from "../../context/session.js"; import { DEFAULT_REQUEST_TIMEOUT } from "../../utils/constants.js"; import { Spinner } from "../exception/loading.js"; -import { QR, QR_Bank, QR_SwissBank } from "../exception/QR.js"; import { doAutoFocus } from "../form/Input.js"; -import { useSessionContext } from "../../context/session.js"; const TALER_SCREEN_ID = 18; @@ -480,7 +481,7 @@ export function ValidBankAccount({ return ( <QR_Bank text={q.qrContent} - label={i18n.str`Banking app`} + label={i18n.str`Bank app`} /> ); case "spc": @@ -575,7 +576,7 @@ export function ValidBankAccount({ </a> , you can use this{" "} <a href={Paytos.toFullString(payto)}>PayTo URI</a> link - instead + instead. </i18n.Translate> </td> <td> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/kyc/list/index.tsx @@ -24,7 +24,7 @@ import { Paytos, TalerError, TalerMerchantApi, - assertUnreachable + assertUnreachable, } from "@gnu-taler/taler-util"; import { useCommonPreferences, diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/details/DetailPage.tsx @@ -34,13 +34,13 @@ import { } from "@gnu-taler/taler-util"; import { NotificationCardBulma, + QR_Taler, RenderAmountBulma, useTranslationContext } from "@gnu-taler/web-util/browser"; import { format, formatDistance } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { QR_Taler } from "../../../../components/exception/QR.js"; import { FormProvider } from "../../../../components/form/FormProvider.js"; import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx b/packages/merchant-backoffice-ui/src/paths/instance/otp_devices/create/CreatedSuccessfully.tsx @@ -15,9 +15,8 @@ */ import { TalerMerchantApi } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_TOTP, useTranslationContext } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; -import { QR, QR_TOTP } from "../../../../components/exception/QR.js"; import { CreatedSuccessfully as Template } from "../../../../components/notifications/CreatedSuccessfully.js"; import { useSessionContext } from "../../../../context/session.js"; diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/qr/QrPage.tsx @@ -24,10 +24,9 @@ import { TalerMerchantApi, TalerUris, } from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_Taler, useTranslationContext } from "@gnu-taler/web-util/browser"; import { h, VNode } from "preact"; import { useRef } from "preact/hooks"; -import { QR_Taler } from "../../../../components/exception/QR.js"; import { useSessionContext } from "../../../../context/session.js"; const TALER_SCREEN_ID = 64; diff --git a/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx b/packages/taler-wallet-webextension/src/components/BankDetailsByPaytoType.tsx @@ -18,7 +18,9 @@ import { AmountJson, Amounts, AmountString, + assertUnreachable, parsePaytoUri, + Paytos, PaytoUriCyclos, PaytoUriIBAN, PaytoUriTaler, @@ -27,11 +29,12 @@ import { PaytoUriUnknown, segwitMinAmount, stringifyPaytoUri, + stringifyPayUri, TranslatedString, WithdrawalExchangeAccountDetails, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_Bank, QR_SwissBank, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; @@ -41,7 +44,6 @@ import { SafeHandler } from "../mui/handlers.js"; import { CopiedIcon, CopyIcon } from "../svg/index.js"; import { Amount } from "./Amount.js"; import { ShowBanksForPaytoPopup } from "./ShowBanksForPaytoPopup.js"; -import { ShowQRsForPaytoPopup } from "./ShowQRsForPaytoPopup.js"; import { ButtonBox, TooltipLeft, WarningBox } from "./styled/index.js"; export interface BankDetailsProps { @@ -194,12 +196,12 @@ function IBANAccountInfoTable({ }: { subject: string; payto: - | PaytoUriUnknown - | PaytoUriIBAN - | PaytoUriTalerBank - | PaytoUriCyclos - | PaytoUriTalerHttp - | PaytoUriTaler; + | PaytoUriUnknown + | PaytoUriIBAN + | PaytoUriTalerBank + | PaytoUriCyclos + | PaytoUriTalerHttp + | PaytoUriTaler; }) { const { i18n } = useTranslationContext(); const api = useBackendContext(); @@ -254,158 +256,201 @@ function IBANAccountInfoTable({ const receiverName = payto.params["receiver-name"] || payto.params["receiver"] || undefined; - const receiverPostalCode = - payto.params["receiver-postal-code"] || undefined; + const receiverPostalCode = payto.params["receiver-postal-code"] || undefined; - const receiverTown = - payto.params["receiver-town"] || undefined; + const receiverTown = payto.params["receiver-town"] || undefined; return ( - <table> - <tbody> - <tr> - <td colSpan={3}> - <i18n.Translate>Step 1:</i18n.Translate> - &nbsp; - <i18n.Translate> - Copy this code and paste it into the subject/purpose field in your - banking app or bank website - </i18n.Translate> - </td> - </tr> - <Row name={i18n.str`Subject`} value={`Taler ${subject}`} literal /> - - <tr> - <td colSpan={3}> - <i18n.Translate>Step 2:</i18n.Translate> - &nbsp; - <i18n.Translate> - If you don't already have it in your banking favourites list, then - copy and paste this IBAN and the name into the receiver fields in - your banking app or website - </i18n.Translate> - </td> - </tr> - {accountPart} - {receiverName ? ( - <Row name={i18n.str`Receiver name`} value={receiverName} /> - ) : undefined} - {receiverPostalCode ? ( - <Row name={i18n.str`Receiver postal code`} value={receiverPostalCode} /> - ) : undefined} - {receiverTown ? ( - <Row name={i18n.str`Receiver town`} value={receiverTown} /> - ) : undefined} - - <tr> - <td colSpan={3}> - <i18n.Translate>Step 3:</i18n.Translate> - &nbsp; - <i18n.Translate> - Finish the wire transfer setting the amount in your banking app or - website, then this withdrawal will proceed automatically. - </i18n.Translate> - </td> - </tr> - <Row - name={i18n.str`Amount`} - value={ - <Amount - value={payto.params["amount"] as AmountString} - hideCurrency - /> - } - /> - - <tr> - <td colSpan={3}> - <WarningBox style={{ margin: 0 }}> - <span> - <i18n.Translate> - Make sure ALL data is correct, including the subject; - otherwise, the money will not arrive in this wallet. You can - use the copy buttons (<CopyIcon />) to prevent typing errors - or the "payto://" URI below to copy just one value. - </i18n.Translate> - </span> - </WarningBox> - </td> - </tr> - - <tr> - <td colSpan={2} width="100%"> - <i18n.Translate> - Alternative if your bank already supports PayTo URI, you can use - this{" "} - <a - target="_bank" - rel="noreferrer" - title="RFC 8905 for designating targets for payments" - href="https://tools.ietf.org/html/rfc8905" - > - PayTo URI - </a>{" "} - link instead - </i18n.Translate> - </td> - <td> - <CopyButton getContent={() => stringifyPaytoUri(payto)} /> - </td> - </tr> - - {banksSites.length < 1 ? undefined : ( - <Fragment> - <div> - <a - href="#" - onClick={(e) => { - setShowBanks(true); - e.preventDefault(); - }} - > + <Fragment> + <tr> + <td colSpan={3}> + {!qrCodes.length ? undefined : ( + <div style={{ overflowY: "auto", height: "95%", padding: 5 }}> + <p> <i18n.Translate> - Continue with banking app or website + If your bank application allows you make a wire transfer using + standard QR codes then try scanning the following: </i18n.Translate> - </a> + </p> + <table style={{ width: "100%" }}> + {qrCodes.map((q, idx) => { + return ( + <tr key={idx}> + <td style={{}}> + {(function () { + switch (q.type) { + case "epc-qr": + return ( + <QR_Bank + text={q.qrContent} + label={i18n.str`Banking app`} + /> + ); + case "spc": + return <QR_SwissBank text={q.qrContent} />; + + default: + assertUnreachable(q.type); + } + })()} + </td> + </tr> + ); + })} + </table> </div> + )} + </td> + </tr> <table> + <tbody> + <tr> + <td colSpan={3}> + <i18n.Translate>Step 1:</i18n.Translate> + &nbsp; + <i18n.Translate> + Copy this code and paste it into the subject/purpose field in + your banking app or bank website + </i18n.Translate> + </td> + </tr> + <Row name={i18n.str`Subject`} value={`Taler ${subject}`} literal /> - {showBanks ? ( - <ShowBanksForPaytoPopup - banks={banksSites} - onClose={{ - onClick: (async () => - setShowBanks(false)) as SafeHandler<void>, - }} - /> - ) : undefined} - </Fragment> - )} - - {qrCodes.length < 1 ? undefined : ( - <Fragment> - <div> - <a - href="#" - onClick={(e) => { - setShowQrs(true); - e.preventDefault(); - }} - > - <i18n.Translate>Show QR code</i18n.Translate> - </a> - </div> - {showQrs ? ( - <ShowQRsForPaytoPopup - qrs={qrCodes} - onClose={{ - onClick: (async () => setShowQrs(false)) as SafeHandler<void>, - }} + <tr> + <td colSpan={3}> + <i18n.Translate>Step 2:</i18n.Translate> + &nbsp; + <i18n.Translate> + If you don't already have it in your banking favourites list, + then copy and paste this IBAN and the name into the receiver + fields in your banking app or website + </i18n.Translate> + </td> + </tr> + {accountPart} + {receiverName ? ( + <Row name={i18n.str`Receiver name`} value={receiverName} /> + ) : undefined} + {receiverPostalCode ? ( + <Row + name={i18n.str`Receiver postal code`} + value={receiverPostalCode} + /> + ) : undefined} + {receiverTown ? ( + <Row name={i18n.str`Receiver town`} value={receiverTown} /> + ) : undefined} + + <tr> + <td colSpan={3}> + <i18n.Translate>Step 3:</i18n.Translate> + &nbsp; + <i18n.Translate> + Finish the wire transfer setting the amount in your banking app + or website, then this withdrawal will proceed automatically. + </i18n.Translate> + </td> + </tr> + <Row + name={i18n.str`Amount`} + value={ + <Amount + value={payto.params["amount"] as AmountString} + hideCurrency /> - ) : undefined} - </Fragment> - )} - </tbody> - </table> + } + /> + + <tr> + <td colSpan={3}> + <WarningBox style={{ margin: 0 }}> + <span> + <i18n.Translate> + Make sure ALL data is correct, including the subject; + otherwise, the money will not arrive in this wallet. You can + use the copy buttons (<CopyIcon />) to prevent typing errors + or the PayTo URI below to copy just one value. + </i18n.Translate> + </span> + </WarningBox> + </td> + </tr> + + <tr> + <td colSpan={2} width="100%"> + <i18n.Translate> + As an alternative, in case that your bank already supports the{" "} + <a + target="_blank" + rel="noreferrer" + title="RFC 8905 for designating targets for payments" + href="https://tools.ietf.org/html/rfc8905" + > + PayTo URI standard + </a> + , you can use this{" "} + <a href={stringifyPaytoUri(payto)}>PayTo URI</a> link instead. + </i18n.Translate> + </td> + <td> + <CopyButton getContent={() => stringifyPaytoUri(payto)} /> + </td> + </tr> + + {banksSites.length < 1 ? undefined : ( + <Fragment> + <div> + <a + href="#" + onClick={(e) => { + setShowBanks(true); + e.preventDefault(); + }} + > + <i18n.Translate> + Continue with banking app or website + </i18n.Translate> + </a> + </div> + + {showBanks ? ( + <ShowBanksForPaytoPopup + banks={banksSites} + onClose={{ + onClick: (async () => + setShowBanks(false)) as SafeHandler<void>, + }} + /> + ) : undefined} + </Fragment> + )} + + {/* {qrCodes.length < 1 ? undefined : ( + <Fragment> + <div> + <a + href="#" + onClick={(e) => { + setShowQrs(true); + e.preventDefault(); + }} + > + <i18n.Translate>Show QR code</i18n.Translate> + </a> + </div> + {showQrs ? ( + <ShowQRsForPaytoPopup + qrs={qrCodes} + onClose={{ + onClick: (async () => + setShowQrs(false)) as SafeHandler<void>, + }} + /> + ) : undefined} + </Fragment> + )} */} + </tbody> + </table> + </Fragment> ); } diff --git a/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx b/packages/taler-wallet-webextension/src/components/PaymentButtons.tsx @@ -17,12 +17,15 @@ import { Amounts, InsufficientBalanceHint, + Result, + TalerUriAction, + TalerUris, TranslatedString, assertUnreachable, parsePayUri } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_Taler, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useBackendContext } from "../context/backend.js"; @@ -30,7 +33,6 @@ import { PaymentStates } from "../cta/Payment/views.js"; import { Button } from "../mui/Button.js"; import { Amount } from "./Amount.js"; import { Part } from "./Part.js"; -import { QR } from "./QR.js"; import { LinkSuccess, WarningBox } from "./styled/index.js"; export function PaymentButtons({ paymentState: state }: { paymentState: PaymentStates }): VNode { @@ -164,11 +166,11 @@ function PayWithMobile({ uri }: { uri: string }): VNode { const { i18n } = useTranslationContext(); const api = useBackendContext(); - const payUri = parsePayUri(uri); + const payUri = Result.orUndefined( TalerUris.fromString(uri)); const [showQR, setShowQR] = useState<string | undefined>(undefined); async function sharePrivatePaymentURI() { - if (!payUri) { + if (!payUri || payUri.type !== TalerUriAction.Pay) { return; } if (!showQR) { @@ -191,7 +193,7 @@ function PayWithMobile({ uri }: { uri: string }): VNode { </LinkSuccess> {showQR && ( <div> - <QR text={showQR} /> + <QR_Taler uri={payUri} /> <i18n.Translate> Scan the QR code or &nbsp; <a href={showQR}> diff --git a/packages/taler-wallet-webextension/src/components/QR.stories.tsx b/packages/taler-wallet-webextension/src/components/QR.stories.tsx @@ -1,31 +0,0 @@ -/* - 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/> - */ - -/** - * - * @author Sebastian Javier Marchano (sebasjm) - */ - -import * as tests from "@gnu-taler/web-util/testing"; -import { QR } from "./QR.js"; - -export default { - title: "qr", -}; - -export const Restore = tests.createExample(QR, { - text: "taler://restore/6J0RZTJC6AV21WXK87BTE67WTHE9P2QSHF2BZXTP7PDZY2ARYBPG@sync1.demo.taler.net,sync2.demo.taler.net,sync1.demo.taler.net,sync3.demo.taler.net", -}); diff --git a/packages/taler-wallet-webextension/src/components/QR.tsx b/packages/taler-wallet-webextension/src/components/QR.tsx @@ -1,49 +0,0 @@ -/* - 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 { h, VNode } from "preact"; -import { useEffect, useRef } from "preact/hooks"; -import qrcode from "qrcode-generator"; - -export function QR({ text }: { text: string }): VNode { - const divRef = useRef<HTMLDivElement>(null); - useEffect(() => { - if (!divRef.current) return; - const qr = qrcode(0, "H"); - qr.addData(text); - qr.make(); - // FIXME: remove innerHTML to prevent firefox warnings - divRef.current.innerHTML = qr.createSvgTag({ - scalable: true, - }); - }); - - return ( - <div - style={{ - width: "100%", - display: "flex", - flexDirection: "column", - alignItems: "center", - }} - > - <div - style={{ width: "50%", minWidth: 200, maxWidth: 300 }} - ref={divRef} - /> - </div> - ); -} diff --git a/packages/taler-wallet-webextension/src/components/ShowQRsForPaytoPopup.tsx b/packages/taler-wallet-webextension/src/components/ShowQRsForPaytoPopup.tsx @@ -1,94 +0,0 @@ -/* - 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 { - QrCodeSpec -} from "@gnu-taler/taler-util"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { styled } from "@linaria/react"; -import { Fragment, h, VNode } from "preact"; -import { ButtonHandler } from "../mui/handlers.js"; -import { Modal } from "./Modal.js"; -import { QR } from "./QR.js"; -import { useState } from "preact/hooks"; - -const QRsTable = styled.table` - width: 100%; - & > tr > td { - padding: 5px; - } - & > tr > td { - border-spacing: 0px; - border-radius: 4px; - border: 1px black solid; - } - & > tr > td:nth-child(2n) { - text-align: right; - overflow-wrap: anywhere; - } -`; - -const AccordionCss = styled.div` -& > .accordion { - color: #444; - cursor: pointer; - padding: 8px; - font-size: large; - width: 100%; - text-align: left; - border: none; - outline: none; - transition: 0.4s; -} - -& > .panel { - padding: 0 18px; - background-color: white; - display: none; - overflow: hidden; -}` - -interface Props { qrs: QrCodeSpec[], onClose: ButtonHandler }; - -function Accordion({ section, content, openedByDefault }: { section: string, content: string, openedByDefault?:boolean }): VNode { - const [opened, setOpened] = useState(!!openedByDefault) - return <AccordionCss> - <button class={opened ? "accordion active" : "accordion"} onClick={() => { setOpened(!opened) }}>{section}</button> - <div class="panel" style={{ display: opened ? "block" : "none" }}> - <QR text={content} /> - </div> - </AccordionCss> -} - -export function ShowQRsForPaytoPopup({ qrs, onClose }: Props): VNode { - const { i18n } = useTranslationContext(); - - return ( - <Modal title="Qrs" onClose={onClose}> - <div style={{ overflowY: "auto", height: "95%", padding: 5 }}> - <QRsTable> - {qrs.map((q, idx) => { - - return <tr key={idx}> - <td> - <Accordion section={q.type} content={q.qrContent} openedByDefault /> - </td> - </tr> - })} - </QRsTable> - </div> - </Modal> - ); -} diff --git a/packages/taler-wallet-webextension/src/components/index.stories.tsx b/packages/taler-wallet-webextension/src/components/index.stories.tsx @@ -24,5 +24,4 @@ export * as a2 from "./PendingTransactions.stories.js"; export * as a3 from "./Amount.stories.js"; export * as a4 from "./ShowFullContractTermPopup.stories.js"; export * as a5 from "./TermsOfService/stories.js"; -export * as a6 from "./QR.stories.js"; export * as a7 from "./AmountField.stories.js"; diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw/views.tsx @@ -14,14 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_Taler, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { Amount } from "../../components/Amount.js"; import { AmountField } from "../../components/AmountField.js"; import { EnabledBySettings } from "../../components/EnabledBySettings.js"; import { Part } from "../../components/Part.js"; -import { QR } from "../../components/QR.js"; import { SelectList } from "../../components/SelectList.js"; import { TermsOfService } from "../../components/TermsOfService/index.js"; import { @@ -39,6 +38,7 @@ import { getAmountWithFee, } from "../../wallet/Transaction.js"; import { State } from "./index.js"; +import { Result, TalerUris } from "@gnu-taler/taler-util"; export function FinalStateOperation(state: State.AlreadyCompleted): VNode { const { i18n } = useTranslationContext(); @@ -246,7 +246,7 @@ export function SuccessView(state: State.Success): VNode { </TermsOfService> </section> {state.talerWithdrawUri ? ( - <WithdrawWithMobile talerWithdrawUri={state.talerWithdrawUri} /> + <WithdrawWithMobile talerWithdrawUri={Result.unpack(TalerUris.fromString(state.talerWithdrawUri))} /> ) : undefined} </Fragment> ); @@ -255,7 +255,7 @@ export function SuccessView(state: State.Success): VNode { function WithdrawWithMobile({ talerWithdrawUri, }: { - talerWithdrawUri: string; + talerWithdrawUri: TalerUris.URI; }): VNode { const { i18n } = useTranslationContext(); const [showQR, setShowQR] = useState<boolean>(false); @@ -267,10 +267,10 @@ function WithdrawWithMobile({ </LinkSuccess> {showQR && ( <div> - <QR text={talerWithdrawUri} /> + <QR_Taler uri={talerWithdrawUri} /> <i18n.Translate> Scan the QR code or &nbsp; - <a href={talerWithdrawUri}> + <a href={TalerUris.toString(talerWithdrawUri)}> <i18n.Translate>click here</i18n.Translate> </a> </i18n.Translate> diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -27,10 +27,12 @@ import { OrderShortInfo, parsePaytoUri, PaytoUri, + Result, ScopeInfo, stringifyPaytoUri, TalerErrorCode, TalerPreciseTimestamp, + TalerUris, Transaction, TransactionAction, TransactionDeposit, @@ -44,7 +46,7 @@ import { WithdrawalType, } from "@gnu-taler/taler-util"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { QR_Taler, useTranslationContext } from "@gnu-taler/web-util/browser"; import { styled } from "@linaria/react"; import { isPast } from "date-fns"; import { ComponentChildren, Fragment, h, VNode } from "preact"; @@ -55,7 +57,6 @@ import { AlertView, ErrorAlertView } from "../components/CurrentAlerts.js"; import { EnabledBySettings } from "../components/EnabledBySettings.js"; import { Loading } from "../components/Loading.js"; import { Kind, Part, PartPayto } from "../components/Part.js"; -import { QR } from "../components/QR.js"; import { ShowFullContractTermPopup } from "../components/ShowFullContractTermPopup.js"; import { CenteredDialog, @@ -970,7 +971,18 @@ export function TransactionView({ !transaction.error && ( <Part title={i18n.str`URI`} - text={<ShowQrWithCopy text={transaction.talerUri} startOpen onForwardToContact={() => { onForwardToContact && onForwardToContact(transaction.transactionId)} }/>} + text={ + <ShowQrWithCopy + uri={Result.unpack( + TalerUris.fromString(transaction.talerUri), + )} + startOpen + onForwardToContact={() => { + onForwardToContact && + onForwardToContact(transaction.transactionId); + }} + /> + } kind="neutral" /> )} @@ -1066,7 +1078,16 @@ export function TransactionView({ {transaction.talerUri && ( <Part title={i18n.str`URI`} - text={<ShowQrWithCopy text={transaction.talerUri} startOpen onForwardToContact={() => { onForwardToContact && onForwardToContact(transaction.transactionId)} } />} + text={ + <ShowQrWithCopy + uri={Result.unpack(TalerUris.fromString(transaction.talerUri))} + startOpen + onForwardToContact={() => { + onForwardToContact && + onForwardToContact(transaction.transactionId); + }} + /> + } kind="neutral" /> )} @@ -1998,18 +2019,19 @@ function NicePayto({ payto }: { payto: PaytoUri }): VNode { } function ShowQrWithCopy({ - text, + uri, startOpen, onForwardToContact, }: { - text: string; + uri: TalerUris.URI; startOpen?: boolean; onForwardToContact?: () => void; }): VNode { const [showing, setShowing] = useState(startOpen); const { i18n } = useTranslationContext(); + const strUri = TalerUris.toString(uri); async function copy(): Promise<void> { - navigator.clipboard.writeText(text); + navigator.clipboard.writeText(strUri); } async function toggle(): Promise<void> { setShowing((s) => !s); @@ -2017,7 +2039,7 @@ function ShowQrWithCopy({ if (showing) { return ( <div> - <QR text={text} /> + <QR_Taler uri={uri} /> <Button onClick={copy as SafeHandler<void>}> <i18n.Translate>copy</i18n.Translate> </Button> @@ -2025,17 +2047,18 @@ function ShowQrWithCopy({ <i18n.Translate>hide qr</i18n.Translate> </Button> <EnabledBySettings name="p2p_aliases"> - {(onForwardToContact) && ( - <Button onClick={onForwardToContact as SafeHandler<void>}> - <i18n.Translate>forward to contact</i18n.Translate> - </Button>)} + {onForwardToContact && ( + <Button onClick={onForwardToContact as SafeHandler<void>}> + <i18n.Translate>forward to contact</i18n.Translate> + </Button> + )} </EnabledBySettings> </div> ); } return ( <div> - <div>{text.substring(0, 64)}...</div> + <div>{strUri.substring(0, 64)}...</div> <Button onClick={copy as SafeHandler<void>}> <i18n.Translate>copy</i18n.Translate> </Button> @@ -2043,10 +2066,11 @@ function ShowQrWithCopy({ <i18n.Translate>show qr</i18n.Translate> </Button> <EnabledBySettings name="p2p_aliases"> - {(onForwardToContact) && ( - <Button onClick={onForwardToContact as SafeHandler<void>}> - <i18n.Translate>forward to contact</i18n.Translate> - </Button>)} + {onForwardToContact && ( + <Button onClick={onForwardToContact as SafeHandler<void>}> + <i18n.Translate>forward to contact</i18n.Translate> + </Button> + )} </EnabledBySettings> </div> ); diff --git a/packages/taler-wallet-webextension/static/font/animations.css b/packages/taler-wallet-webextension/static/font/animations.css @@ -0,0 +1,15 @@ +/** + * Animations for the Taler QR frame + */ + +@property --angle { + syntax: "<angle>"; + initial-value: 0deg; + inherits: false; +} + +@keyframes rotate { + to { + --angle: 360deg; + } +} diff --git a/packages/taler-wallet-webextension/static/popup.html b/packages/taler-wallet-webextension/static/popup.html @@ -33,6 +33,7 @@ </style> <link rel="stylesheet" type="text/css" href="/dist/popupEntryPoint.css" /> <link rel="stylesheet" type="text/css" href="/static/font/import.css" /> + <link rel="stylesheet" type="text/css" href="/static/font/animations.css" /> <link rel="icon" href="/static/img/icon.png" /> <script src="/dist/popupEntryPoint.js"></script> </head> diff --git a/packages/taler-wallet-webextension/static/wallet.html b/packages/taler-wallet-webextension/static/wallet.html @@ -9,6 +9,7 @@ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; connect-src *; img-src 'self' data:; style-src-elem 'unsafe-inline' 'self'; style-src 'unsafe-inline' 'self';font-src 'self';base-uri 'self'; frame-src * data: 'self'; form-action 'self'"> <link rel="stylesheet" type="text/css" href="/dist/walletEntryPoint.css" /> <link rel="stylesheet" type="text/css" href="/static/font/import.css" /> + <link rel="stylesheet" type="text/css" href="/static/font/animations.css" /> <link rel="icon" href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" /> <script src="/dist/walletEntryPoint.js"></script> diff --git a/packages/taler-wallet-webextension/tsconfig.json b/packages/taler-wallet-webextension/tsconfig.json @@ -7,6 +7,9 @@ "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories "skipLibCheck": true, "preserveSymlinks": true, + "paths": { + "@assets/*": ["../../contrib/taler-assets/*"], + }, "outDir": "lib", "rootDir": "./src" }, diff --git a/packages/web-util/package.json b/packages/web-util/package.json @@ -64,6 +64,7 @@ "@babel/core": "7.18.9", "@babel/helper-compilation-targets": "7.18.9", "@types/chrome": "0.0.197", + "qrcode-generator": "1.4.4", "tailwindcss": "3.4.17" } } diff --git a/packages/web-util/src/components/QR.tsx b/packages/web-util/src/components/QR.tsx @@ -0,0 +1,239 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 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 logo from "@assets/svg/logo/qr-logo.svg"; +import chFlag from "@assets/svg/swiss-qr-flag.svg"; +import { TalerUri, TalerUris, TranslatedString } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import qrcode from "qrcode-generator"; + +function generate_qr( + text: string, + params: { + typeNumber?: TypeNumber; + errorCorrectionLevel?: ErrorCorrectionLevel; + } = {}, +) { + const qr = qrcode( + params.typeNumber ?? 0, + (params.errorCorrectionLevel = "H"), + ); + qr.addData(text, "Byte"); + qr.make(); + const image = qr.createSvgTag({ + scalable: true, + margin: 0, + }); + return `data:image/svg+xml,${encodeURIComponent(image)}`; +} + +export function QR_Taler({ uri }: { uri: TalerUri }): VNode { + const stringUri = TalerUris.toString(uri); + return ( + <div + style={{ + width: "100%", + height: "100%", + maxWidth: 400, + margin: "auto", + padding: 10, // size of the animated border + borderRadius: 20, + background: `conic-gradient( + from var(--angle), + #0042B3 0deg, + #F1F1F4 20deg, + #F1F1F4 150deg, + #F1F1F4 160deg, + #0042B3 180deg, + #F1F1F4 200deg, + #F1F1F4 330deg, + #F1F1F4 340deg, + #0042B3) padding-box`, + animation: "rotate 10s linear infinite", + position: "relative", + }} + > + <div + style={{ + padding: 10, // separate the qr from the animated border + borderRadius: 20, // match the radius from the parent + backgroundColor: "white", // hide the background + }} + > + <img style={{ margin: 5 }} src={generate_qr(stringUri)} /> + </div> + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + alignContent: "center", + display: "flex", + backgroundColor: "transparent", // section 19.90.4.3 + }} + > + <img src={logo} style={{ width: 100, height: 50 }} /> + </div> + </div> + ); +} + +export function QR_TOTP({ otpAuthURI }: { otpAuthURI: string }): VNode { + return ( + <div + style={{ + width: "90%", + maxWidth: 400, + margin: "auto", + padding: 10, + position: "relative", + }} + > + <div + style={{ + padding: 10, // separate the qr from the animated border + borderRadius: 20, // match the radius from the parent + backgroundColor: "white", // hide the background + }} + > + <img style={{ margin: 5 }} src={generate_qr(otpAuthURI)} /> + </div> + + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + alignContent: "center", + }} + > + <div + style={{ + display: "flex", + border: "1px solid black", + backgroundColor: "white", + fontWeight: "bold", + padding: 5, + }} + > + T-OTP + </div> + </div> + </div> + ); +} + +/** + * Based on the definition of Swiss Implementation Guidelines + * for the QR-bill + * @param param0 + * @returns + */ +export function QR_SwissBank({ text }: { text: string }): VNode { + return ( + <div + style={{ + width: "90%", + maxWidth: 300, + margin: "auto", + padding: 10, + position: "relative", + }} + > + <div + style={{ + padding: 10, // separate the qr from the animated border + borderRadius: 20, // match the radius from the parent + backgroundColor: "white", // hide the background + }} + > + <img + style={{ margin: 5 }} + src={generate_qr(text, { + typeNumber: 0, // section 6.2 + errorCorrectionLevel: "M", // section 6.1 + })} + /> + </div> + + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + alignContent: "center", + }} + > + <div + style={{ + display: "flex", + backgroundColor: "white", + }} + > + <img src={chFlag} style={{ width: 50, height: 50 }} /> + </div> + </div> + </div> + ); +} + +export function QR_Bank({ + text, + label, +}: { + text: string; + label: TranslatedString; +}): VNode { + return ( + <div + style={{ + width: "90%", + maxWidth: 300, + margin: "auto", + position: "relative", + }} + > + <div + style={{ + padding: 10, // separate the qr from the animated border + borderRadius: 20, // match the radius from the parent + backgroundColor: "white", // hide the background + }} + > + <img style={{ margin: 5 }} src={generate_qr(text)} /> + </div> + + <div + style={{ + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + alignContent: "center", + }} + > + <div + style={{ fontWeight: "bold", backgroundColor: "white", padding: 3 }} + > + {label} + </div> + </div> + </div> + ); +} diff --git a/packages/web-util/src/components/index.ts b/packages/web-util/src/components/index.ts @@ -14,4 +14,5 @@ export * from "./ToastBanner.js"; export * from "./Time.js"; export * from "./RenderAmount.js"; export * from "./Pagination.js"; +export * from "./QR.js"; export * from "./NotificationCardBulma.js"; diff --git a/packages/web-util/tsconfig.json b/packages/web-util/tsconfig.json @@ -10,6 +10,9 @@ "preserveSymlinks": true, // FIXME: Eventually, code should compile without this. "skipLibCheck": true, + "paths": { + "@assets/*": ["../../contrib/taler-assets/*"], + }, "rootDir": "./src", "typeRoots": ["./node_modules/@types"] }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml @@ -1161,6 +1161,9 @@ importers: '@types/chrome': specifier: 0.0.197 version: 0.0.197 + qrcode-generator: + specifier: 1.4.4 + version: 1.4.4 tailwindcss: specifier: 3.4.17 version: 3.4.17(ts-node@10.9.1(@types/node@18.11.17)(typescript@5.7.3))