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