taler-typescript-core

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

commit 4428677a393bcde8a356640dc4c906f840bd764b
parent 7c35e9cc4b0791e98da14327620cf884bf970248
Author: Sebastian <sebasjm@gmail.com>
Date:   Fri, 14 Nov 2025 15:59:40 -0300

personas

Diffstat:
Mpackages/merchant-backoffice-ui/src/components/menu/SideBar.tsx | 203++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mpackages/merchant-backoffice-ui/src/hooks/preference.ts | 42++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx | 17+++++++++++++----
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx | 8++++++--
Mpackages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx | 8++++++--
Mpackages/merchant-backoffice-ui/src/paths/settings/index.tsx | 53++++++++++++++++++++++++++++++++++++++---------------
6 files changed, 269 insertions(+), 62 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx b/packages/merchant-backoffice-ui/src/components/menu/SideBar.tsx @@ -24,11 +24,15 @@ import { MerchantAccountKycStatusSimplified, TalerError, } from "@gnu-taler/taler-util"; -import { useCommonPreferences, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, h, VNode } from "preact"; +import { + useCommonPreferences, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useSessionContext } from "../../context/session.js"; import { useInstanceKYCDetailsLongPolling } from "../../hooks/instance.js"; import { LangSelector } from "./LangSelector.js"; +import { Personas, UIElement, usePreference } from "../../hooks/preference.js"; // const GIT_HASH = typeof __GIT_HASH__ !== "undefined" ? __GIT_HASH__ : undefined; const VERSION = typeof __VERSION__ !== "undefined" ? __VERSION__ : undefined; @@ -41,7 +45,6 @@ export function Sidebar({ mobile }: Props): VNode { const { i18n } = useTranslationContext(); const { state, logOut, config } = useSessionContext(); const kycStatus = useInstanceKYCDetailsLongPolling(); - const [{ showDebugInfo }] = useCommonPreferences(); const allKycData = kycStatus !== undefined && @@ -92,7 +95,10 @@ export function Sidebar({ mobile }: Props): VNode { {isLoggedIn ? ( <Fragment> <ul class="menu-list"> - <li> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_orders} + > <a href={"#/orders"} class="has-icon"> <span class="icon"> <i class="mdi mdi-cash-register" /> @@ -101,8 +107,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Orders</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_inventory} + > <a href={"#/inventory"} class="has-icon"> <span class="icon"> <i class="mdi mdi-shopping" /> @@ -111,8 +120,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Inventory</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_categories} + > <a href={"#/category"} class="has-icon"> <span class="icon"> <i class="mdi mdi-label-outline" /> @@ -121,8 +133,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Categories</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_wireTransfers} + > <a href={"#/transfers"} class="has-icon"> <span class="icon"> <i class="mdi mdi-arrow-left-right" /> @@ -131,8 +146,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Wire transfers</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_templates} + > <a href={"#/templates"} class="has-icon"> <span class="icon"> <i class="mdi mdi-qrcode" /> @@ -141,21 +159,24 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Templates</i18n.Translate> </span> </a> - </li> - {showDebugInfo ? ( - <li> - <a href={"#/tokenfamilies"} class="has-icon"> - <span class="icon"> - <i class="mdi mdi-clock" /> - </span> - <span class="menu-item-label"> - <i18n.Translate>Token Families</i18n.Translate> - </span> - </a> - </li> - ) : undefined} + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_tokenFamilies} + > + <a href={"#/tokenfamilies"} class="has-icon"> + <span class="icon"> + <i class="mdi mdi-clock" /> + </span> + <span class="menu-item-label"> + <i18n.Translate>Token Families</i18n.Translate> + </span> + </a> + </HtmlPersonaFlag> {!allKycData.length ? undefined : ( - <li + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_kycStatus} class={ simplifiedKycStatus === MerchantAccountKycStatusSimplified.WARNING @@ -199,14 +220,17 @@ export function Sidebar({ mobile }: Props): VNode { </span> <span class="menu-item-label">KYC Status</span> </a> - </li> + </HtmlPersonaFlag> )} </ul> <p class="menu-label"> <i18n.Translate>Configuration</i18n.Translate> </p> <ul class="menu-list"> - <li> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_bankAccounts} + > <a href={"#/bank"} class="has-icon"> <span class="icon"> <i class="mdi mdi-bank" /> @@ -215,8 +239,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Bank account</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_otpDevices} + > <a href={"#/otp-devices"} class="has-icon"> <span class="icon"> <i class="mdi mdi-lock" /> @@ -225,8 +252,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>OTP Devices</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_webhooks} + > <a href={"#/webhooks"} class="has-icon"> <span class="icon"> <i class="mdi mdi-webhook" /> @@ -235,8 +265,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Webhooks</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_settings} + > <a href={"#/settings"} class="has-icon"> <span class="icon"> <i class="mdi mdi-square-edit-outline" /> @@ -245,8 +278,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Settings</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_password} + > <a href={"#/password"} class="has-icon"> <span class="icon"> <i class="mdi mdi-security" /> @@ -255,8 +291,11 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Password</i18n.Translate> </span> </a> - </li> - <li> + </HtmlPersonaFlag> + <HtmlPersonaFlag + htmlElement="li" + point={UIElement.sidebar_accessTokens} + > <a href={"#/access-token"} class="has-icon"> <span class="icon"> <i class="mdi mdi-key" /> @@ -265,7 +304,7 @@ export function Sidebar({ mobile }: Props): VNode { <i18n.Translate>Access tokens</i18n.Translate> </span> </a> - </li> + </HtmlPersonaFlag> </ul> </Fragment> ) : undefined} @@ -349,3 +388,89 @@ export function Sidebar({ mobile }: Props): VNode { </aside> ); } + +type ElementMap = { + [sdt in UIElement]?: true; +}; +const ALL_ELEMENTS = Object.values(UIElement).reduce((prev, ui) => { + prev[ui as UIElement] = true; + return prev; +}, {} as ElementMap); +function getAvailableForPersona(p: Personas): ElementMap { + switch (p) { + case "expert": + return ALL_ELEMENTS; + case "offline-vending-machine": + return { + [UIElement.sidebar_orders]: true, + [UIElement.sidebar_templates]: true, + [UIElement.sidebar_kycStatus]: true, + [UIElement.sidebar_bankAccounts]: true, + [UIElement.sidebar_settings]: true, + [UIElement.sidebar_password]: true, + }; + case "point-of-sale": + return { + [UIElement.sidebar_orders]: true, + [UIElement.sidebar_templates]: true, + [UIElement.sidebar_inventory]: true, + [UIElement.sidebar_categories]: true, + [UIElement.sidebar_kycStatus]: true, + [UIElement.sidebar_bankAccounts]: true, + [UIElement.sidebar_settings]: true, + [UIElement.sidebar_password]: true, + }; + case "digital-publishing": + return { + [UIElement.sidebar_orders]: true, + [UIElement.sidebar_templates]: true, + // only once v1.6 + // [UIElement.sidebar_tokenFamilies]: true, + [UIElement.sidebar_kycStatus]: true, + [UIElement.sidebar_bankAccounts]: true, + [UIElement.sidebar_settings]: true, + [UIElement.sidebar_password]: true, + }; + + case "e-commerce": + return { + [UIElement.sidebar_orders]: true, + [UIElement.sidebar_templates]: true, + // only once v1.6 + // [UIElement.sidebar_tokenFamilies]: true, + [UIElement.sidebar_webhooks]: true, + [UIElement.sidebar_accessTokens]: true, + [UIElement.sidebar_kycStatus]: true, + [UIElement.sidebar_bankAccounts]: true, + [UIElement.sidebar_settings]: true, + [UIElement.sidebar_password]: true, + }; + } +} + +export function HtmlPersonaFlag<T extends keyof h.JSX.IntrinsicElements>( + props: { + htmlElement: T; + point: UIElement; + children: ComponentChildren; + } & h.JSX.IntrinsicElements[T], +): VNode | null { + const { htmlElement: el, children, point, ...rest } = props; + const [{ persona }] = usePreference(); + const isEnabled = getAvailableForPersona(persona)[point]; + if (isEnabled) return h(el as any, rest as any, children); + return null; +} + +export function ComponentPersonaFlag<FN extends (props:P) => VNode, P>(props: { + Comp: FN, + point: UIElement; + children: ComponentChildren; +} & P): VNode | null { + const { children, point, Comp, ...rest } = props; + const [{ persona }] = usePreference(); + const isEnabled = getAvailableForPersona(persona)[point]; + const d = rest as any + if (isEnabled) return <Comp {...d}>{children}</Comp>; + return null; +} diff --git a/packages/merchant-backoffice-ui/src/hooks/preference.ts b/packages/merchant-backoffice-ui/src/hooks/preference.ts @@ -25,12 +25,43 @@ import { } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +export type Personas = + | "expert" + | "offline-vending-machine" + | "point-of-sale" + | "digital-publishing" + | "e-commerce"; + +export enum UIElement { + sidebar_orders, + sidebar_inventory, + sidebar_categories, + sidebar_wireTransfers, + sidebar_templates, + sidebar_kycStatus, + sidebar_bankAccounts, + sidebar_otpDevices, + sidebar_webhooks, + sidebar_tokenFamilies, + sidebar_subscriptions, + sidebar_discounts, + sidebar_settings, + sidebar_password, + sidebar_accessTokens, + // sidebar_interfaces, + // sidebar_instanceNew, + // sidebar_instanceList, + action_manuallyCreatingOrders, + option_otpDevicesOnTemplate, +} + export interface Preferences { advanceOrderMode: boolean; advanceInstanceMode: boolean; hideKycUntil: AbsoluteTime; hideMissingAccountUntil: AbsoluteTime; dateFormat: "ymd" | "dmy" | "mdy"; + persona: Personas; } const defaultSettings: Preferences = { @@ -39,6 +70,7 @@ const defaultSettings: Preferences = { hideKycUntil: AbsoluteTime.never(), hideMissingAccountUntil: AbsoluteTime.never(), dateFormat: "ymd", + persona: "expert", }; export const codecForPreferences = (): Codec<Preferences> => @@ -55,6 +87,16 @@ export const codecForPreferences = (): Codec<Preferences> => codecForConstString("mdy"), ), ) + .property( + "persona", + codecForEither( + codecForConstString("expert"), + codecForConstString("offline-vending-machine"), + codecForConstString("point-of-sale"), + codecForConstString("digital-publishing"), + codecForConstString("e-commerce"), + ), + ) .build("Preferences"); const PREFERENCES_KEY = buildStorageKey( diff --git a/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx b/packages/merchant-backoffice-ui/src/paths/instance/orders/list/Table.tsx @@ -35,7 +35,7 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { format } from "date-fns"; -import { Fragment, VNode, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { StateUpdater, useState } from "preact/hooks"; import { FormErrors, @@ -45,14 +45,18 @@ import { Input } from "../../../../components/form/Input.js"; import { InputCurrency } from "../../../../components/form/InputCurrency.js"; import { InputGroup } from "../../../../components/form/InputGroup.js"; import { InputSelector } from "../../../../components/form/InputSelector.js"; +import { + HtmlPersonaFlag +} from "../../../../components/menu/SideBar.js"; import { ConfirmModal } from "../../../../components/modal/index.js"; import { useSessionContext } from "../../../../context/session.js"; +import { WithId } from "../../../../declaration.js"; import { datetimeFormatForSettings, + UIElement, usePreference, } from "../../../../hooks/preference.js"; import { mergeRefunds } from "../../../../utils/amount.js"; -import { WithId } from "../../../../declaration.js"; type Entity = TalerMerchantApi.OrderHistoryEntry & WithId; interface Props { @@ -88,7 +92,12 @@ export function CardTable({ <div class="card-header-icon" aria-label="more options" /> - <div class="card-header-icon" aria-label="more options"> + <HtmlPersonaFlag + htmlElement="div" + point={UIElement.action_manuallyCreatingOrders} + class="card-header-icon" + aria-label="more options" + > <span class="has-tooltip-left" data-tooltip={i18n.str`Create order`}> <button class="button is-info" @@ -101,7 +110,7 @@ export function CardTable({ </span> </button> </span> - </div> + </HtmlPersonaFlag> </header> <div class="card-content"> <div class="b-table has-pagination"> diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/create/CreatePage.tsx @@ -50,6 +50,8 @@ import { InputWithAddon } from "../../../../components/form/InputWithAddon.js"; import { TextField } from "../../../../components/form/TextField.js"; import { useSessionContext } from "../../../../context/session.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; +import { ComponentPersonaFlag } from "../../../../components/menu/SideBar.js"; +import { UIElement } from "../../../../hooks/preference.js"; // type Entity = TalerMerchantApi.TemplateAddDetails & { type: Steps }; type Entity = { @@ -267,7 +269,9 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { tooltip={i18n.str`How much time the customer has to complete the payment once the order was created.`} /> {!deviceList.length ? ( - <TextField + <ComponentPersonaFlag + Comp={TextField} + point={UIElement.option_otpDevicesOnTemplate} name="otpId" label={i18n.str`OTP device`} tooltip={i18n.str`Use to verify transactions in offline mode.`} @@ -276,7 +280,7 @@ export function CreatePage({ onCreated, onBack }: Props): VNode { <a href="#/otp-devices/new"> <i18n.Translate>Add one first</i18n.Translate> </a> - </TextField> + </ComponentPersonaFlag> ) : ( <InputSelector<Entity> name="otpId" diff --git a/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/templates/update/UpdatePage.tsx @@ -51,6 +51,8 @@ import { NotificationCard } from "../../../../components/menu/index.js"; import { useSessionContext } from "../../../../context/session.js"; import { WithId } from "../../../../declaration.js"; import { useInstanceOtpDevices } from "../../../../hooks/otp.js"; +import { UIElement } from "../../../../hooks/preference.js"; +import { ComponentPersonaFlag } from "../../../../components/menu/SideBar.js"; type Entity = { description?: string; @@ -335,7 +337,9 @@ export function UpdatePage({ template, onUpdated, onBack }: Props): VNode { tooltip={i18n.str`How much time has the customer to complete the payment once the order was created.`} /> {!deviceList.length ? ( - <TextField + <ComponentPersonaFlag + Comp={TextField} + point={UIElement.option_otpDevicesOnTemplate} name="otpId" label={i18n.str`OTP device`} tooltip={i18n.str`Use to verify transactions in offline mode.`} @@ -344,7 +348,7 @@ export function UpdatePage({ template, onUpdated, onBack }: Props): VNode { <a href="#/otp-devices/new"> <i18n.Translate>Add one first</i18n.Translate> </a> - </TextField> + </ComponentPersonaFlag> ) : ( <InputSelector<Entity> name="otpId" diff --git a/packages/merchant-backoffice-ui/src/paths/settings/index.tsx b/packages/merchant-backoffice-ui/src/paths/settings/index.tsx @@ -27,9 +27,13 @@ import { import { InputSelector } from "../../components/form/InputSelector.js"; import { InputToggle } from "../../components/form/InputToggle.js"; import { LangSelector } from "../../components/menu/LangSelector.js"; -import { Preferences, usePreference } from "../../hooks/preference.js"; +import { + Preferences, + Personas, + usePreference, +} from "../../hooks/preference.js"; -type FormType = Preferences & { developerMode: boolean }; +type FormType = Preferences; export function Settings({ onClose }: { onClose?: () => void }): VNode { const { i18n } = useTranslationContext(); @@ -46,12 +50,11 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { next.hideMissingAccountUntil ?? AbsoluteTime.never(), hideKycUntil: next.hideKycUntil ?? AbsoluteTime.never(), dateFormat: next.dateFormat ?? "ymd", + persona: next.persona ?? "expert", }; - if ( - next.developerMode !== undefined && - next.developerMode !== showDebugInfo - ) { - updateCommonPref("showDebugInfo", next.developerMode); + const isDeveloper = next.persona === "expert"; + if (isDeveloper !== showDebugInfo) { + updateCommonPref("showDebugInfo", isDeveloper); } updateValue(v); @@ -67,10 +70,7 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { <FormProvider<FormType> name="settings" errors={errors} - object={{ - ...value, - developerMode: showDebugInfo, - }} + object={value} valueHandler={valueHandler} > <div class="field is-horizontal"> @@ -123,10 +123,33 @@ export function Settings({ onClose }: { onClose?: () => void }): VNode { values={["ymd", "mdy", "dmy"]} tooltip={i18n.str`How the date is going to be displayed`} /> - <InputToggle<FormType> - label={i18n.str`Developer mode`} - tooltip={i18n.str`Shows more options and tools that are not intended for a general audience.`} - name="developerMode" + <InputSelector<FormType> + label={i18n.str`Persona`} + tooltip={i18n.str`Simplify UI based on the user usage.`} + name="persona" + values={ + [ + "expert", + "digital-publishing", + "e-commerce", + "offline-vending-machine", + "point-of-sale", + ] as Personas[] + } + toStr={(e: Personas) => { + switch (e) { + case "expert": + return i18n.str`Power user`; + case "offline-vending-machine": + return i18n.str`Offline venfing machine`; + case "point-of-sale": + return i18n.str`In-person point of sale.`; + case "digital-publishing": + return i18n.str`Digital publishing`; + case "e-commerce": + return i18n.str`E-commerce site`; + } + }} /> </FormProvider> </div>