taler-typescript-core

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

commit 23e2873dcbd48f34816fb7f7b9effdd6fbeda9db
parent 5d813d358e1a61e1b7f3827326b7fc3a4804710b
Author: Florian Dold <dold@taler.net>
Date:   Thu,  5 Nov 2026 09:29:56 +0100

merchant web UI: hide category IDs and restore unpaid order details

Diffstat:
Mpackages/taler-merchant-webui/src/api/hooks.test.ts | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useOrders.ts | 107++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackages/taler-merchant-webui/src/i18n/de-CH.po | 11+++++++----
Mpackages/taler-merchant-webui/src/i18n/de.po | 11+++++++----
Mpackages/taler-merchant-webui/src/i18n/fr.po | 11+++++++----
Mpackages/taler-merchant-webui/src/i18n/it.po | 11+++++++----
Mpackages/taler-merchant-webui/src/i18n/strings.ts | 8++++----
Mpackages/taler-merchant-webui/src/i18n/taler-merchant-webui.pot | 8++++----
Mpackages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx | 1+
Mpackages/taler-merchant-webui/src/screens/InventoryScreen.tsx | 9+--------
Mpackages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx | 4++--
Mpackages/taler-merchant-webui/src/screens/OrderDetailScreen.tsx | 18+++++++++++-------
Mpackages/taler-merchant-webui/src/screens/order-detail/OrderTimeline.tsx | 2+-
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 37+++++++++++++++++++++++++++++++++++--
Mpackages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx | 24++++++++++++++++++------
Mpackages/taler-merchant-webui/src/screens/token-setup/wizard.test.tsx | 10+++++-----
Mpackages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx | 1+
17 files changed, 233 insertions(+), 88 deletions(-)

diff --git a/packages/taler-merchant-webui/src/api/hooks.test.ts b/packages/taler-merchant-webui/src/api/hooks.test.ts @@ -20,6 +20,8 @@ import { isNonZeroAmount, mapOrderStatus, resolveOrderPayment, + resolveOrderContractDetails, + resolveOrderPayDeadline, orderStatusFilter, accessScopeLabel, paginateAccessTokens, @@ -288,6 +290,52 @@ function unpaidV1( }; } +test("unpaid contract facts come from proto_contract_terms", () => { + const response = unpaidV1(); + response.proto_contract_terms = { + ...response.proto_contract_terms, + pay_deadline: { t_s: 2_000_000_000 }, + refund_deadline: { t_s: 2_000_000_100 }, + wire_transfer_deadline: { t_s: 2_000_000_200 }, + fulfillment_url: "https://shop.example/receipt", + products: [{ product_id: "coffee", description: "Coffee", quantity: 2 }], + }; + const facts = resolveOrderContractDetails(response); + assert.equal(facts.createdAt?.t_ms, 1_770_000_000_000); + assert.equal(facts.payDeadline?.t_ms, 2_000_000_000_000); + assert.equal(facts.refundDeadline?.t_ms, 2_000_000_100_000); + assert.equal(facts.wireDeadline?.t_ms, 2_000_000_200_000); + assert.equal(facts.fulfillmentUrl, "https://shop.example/receipt"); + assert.deepEqual(facts.products, response.proto_contract_terms.products); + const claimed = { + order_status: "claimed" as const, + contract_terms: { + ...response.proto_contract_terms, + timestamp: response.creation_time, + }, + order_status_url: response.order_status_url, + }; + assert.deepEqual(resolveOrderContractDetails(claimed), facts); +}); + +test("payment deadlines prefer the response field and distinguish never from missing", () => { + const response = unpaidV1(); + response.proto_contract_terms = { pay_deadline: { t_s: 1 } }; + assert.equal(mapOrderStatus(response), "expired"); + response.pay_deadline = { t_s: 4_000_000_000 }; + assert.equal(resolveOrderPayDeadline(response)?.t_ms, 4_000_000_000_000); + assert.equal(mapOrderStatus(response), "offered"); + delete response.pay_deadline; + response.proto_contract_terms.pay_deadline = { t_s: "never" }; + assert.deepEqual(resolveOrderPayDeadline(response), AbsoluteTime.never()); + assert.equal(mapOrderStatus(response), "offered"); + delete response.proto_contract_terms.pay_deadline; + assert.equal(resolveOrderPayDeadline(response), undefined); + delete response.proto_contract_terms; + assert.equal(resolveOrderPayDeadline(response), undefined); + assert.equal(resolveOrderContractDetails(response).products, undefined); +}); + function paidV1( choiceIndex: number | undefined, ): TalerMerchantApi.CheckPaymentPaidResponse { diff --git a/packages/taler-merchant-webui/src/api/hooks/useOrders.ts b/packages/taler-merchant-webui/src/api/hooks/useOrders.ts @@ -46,6 +46,18 @@ export function protocolTimestampToAbsoluteTime( return ms === undefined ? undefined : AbsoluteTime.fromMilliseconds(ms); } +/** Unpaid responses may expose the deadline only in the proposed contract. */ +export function resolveOrderPayDeadline( + body: TalerMerchantApi.MerchantOrderStatusResponse, +): import("@gnu-taler/taler-util").AbsoluteTime | undefined { + const value: unknown = + body.order_status === "unpaid" + ? (body.pay_deadline ?? orderContract(body)?.pay_deadline) + : orderContract(body)?.pay_deadline; + if (isRecord(value) && value.t_s === "never") return AbsoluteTime.never(); + return protocolTimestampToAbsoluteTime(value); +} + export interface UseOrdersParams { /** Do not mount list or live requests until the owning route needs them. */ enabled?: boolean; @@ -140,9 +152,11 @@ export function mapOrderStatus( if (isPaid) return "paid"; const payDeadlineMs = - "pay_deadline" in o - ? AbsoluteTime.parseTimestampMs(o.pay_deadline) - : undefined; + "order_status" in o + ? AbsoluteTime.parseTimestampMs(resolveOrderPayDeadline(o)) + : "pay_deadline" in o + ? AbsoluteTime.parseTimestampMs(o.pay_deadline) + : undefined; const isExpired = queryStatus === "expired" || (payDeadlineMs !== undefined && payDeadlineMs < Date.now()); @@ -156,6 +170,61 @@ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } +function orderContract( + body: TalerMerchantApi.MerchantOrderStatusResponse, +): Record<string, unknown> | undefined { + const value: unknown = + body.order_status === "unpaid" + ? body.proto_contract_terms + : body.contract_terms; + return isRecord(value) ? value : undefined; +} + +/** Read shared order facts from either proposed or claimed contract terms. */ +export function resolveOrderContractDetails( + body: TalerMerchantApi.MerchantOrderStatusResponse, +): Pick< + OrderDetail, + | "createdAt" + | "payDeadline" + | "refundDeadline" + | "wireDeadline" + | "fulfillmentUrl" + | "products" +> { + const terms = orderContract(body); + return { + createdAt: protocolTimestampToAbsoluteTime( + body.order_status === "unpaid" + ? (body.creation_time ?? terms?.timestamp) + : terms?.timestamp, + ), + payDeadline: resolveOrderPayDeadline(body), + refundDeadline: protocolTimestampToAbsoluteTime(terms?.refund_deadline), + wireDeadline: protocolTimestampToAbsoluteTime( + terms?.wire_transfer_deadline, + ), + fulfillmentUrl: + typeof terms?.fulfillment_url === "string" + ? terms.fulfillment_url + : undefined, + products: Array.isArray(terms?.products) + ? terms.products.filter(isRecord).map((product) => ({ + description: + typeof product.description === "string" + ? product.description + : undefined, + product_id: + typeof product.product_id === "string" + ? product.product_id + : undefined, + quantity: + typeof product.quantity === "number" ? product.quantity : undefined, + })) + : undefined, + }; +} + function amountString(value: unknown): string | undefined { return typeof value === "string" && Amounts.checkString(value) ? value @@ -282,12 +351,7 @@ export function resolveOrderPayment( body: TalerMerchantApi.MerchantOrderStatusResponse, ): ResolvedOrderPayment { const unpaid = body.order_status === "unpaid" ? body : undefined; - const rawContract: unknown = - unpaid?.proto_contract_terms ?? - (body.order_status === "paid" || body.order_status === "claimed" - ? body.contract_terms - : undefined); - const contract = isRecord(rawContract) ? rawContract : undefined; + const contract = orderContract(body); const isV1 = contract?.version === 1 || Array.isArray(contract?.choices); if (!isV1) { @@ -523,8 +587,6 @@ export function useOrderDetails(orderId?: string) { body.order_status === "unpaid" ? undefined : body.contract_terms; const payment = resolveOrderPayment(body); const amountVal = payment.amount; - const products = terms?.products; - const fulfillmentUrl = terms?.fulfillment_url; const payUrl = unpaid?.taler_pay_uri; const claimed = body.order_status === "claimed"; @@ -555,21 +617,10 @@ export function useOrderDetails(orderId?: string) { ) .sort(AbsoluteTime.cmp)[0]; - const createdAt = protocolTimestampToAbsoluteTime( - terms?.timestamp ?? unpaid?.creation_time, - ); const paidAt = protocolTimestampToAbsoluteTime(paid?.last_payment); - const payDeadline = protocolTimestampToAbsoluteTime( - terms?.pay_deadline ?? unpaid?.pay_deadline, - ); - const refundDeadline = protocolTimestampToAbsoluteTime( - terms?.refund_deadline, - ); - const wireDeadline = protocolTimestampToAbsoluteTime( - terms?.wire_transfer_deadline, - ); return { + ...resolveOrderContractDetails(body), orderId: terms?.order_id || orderId!, summary: terms?.summary || unpaid?.summary || orderId!, amount: amountVal, @@ -577,20 +628,10 @@ export function useOrderDetails(orderId?: string) { selectedChoiceIndex: payment.selectedChoiceIndex, status, claimed, - createdAt, paidAt, payUrl, refundUrl: orderRefundUri(accountBaseUrl(config).href, orderId!), - payDeadline, - refundDeadline, - wireDeadline, expectedMinAmount: paid?.deposit_total || amountVal, - fulfillmentUrl, - products: products?.map((p) => ({ - description: p.description, - product_id: p.product_id, - quantity: p.quantity, - })), transfers: (paid?.wire_details ?? []).map((tr, idx) => { const executionTime = protocolTimestampToAbsoluteTime( tr.execution_time, diff --git a/packages/taler-merchant-webui/src/i18n/de-CH.po b/packages/taler-merchant-webui/src/i18n/de-CH.po @@ -5058,10 +5058,6 @@ msgstr "%1$s Produkte" msgid "Category Name" msgstr "Kategoriename" -#: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:692 -msgid "Category ID" -msgstr "Kategorie-ID" - #: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:745 msgid "Rename Category" msgstr "Kategorie umbenennen" @@ -8240,6 +8236,10 @@ msgstr "Nicht verfügbar — vor dem Speichern entfernen" msgid "Remove %1$s (%2$s)" msgstr "%1$s (%2$s) entfernen" +#: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:296 +msgid "Remove %1$s" +msgstr "%1$s entfernen" + #: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:444 msgid "No matches" msgstr "Keine Treffer" @@ -15178,6 +15178,9 @@ msgstr "Storybook wird geladen …" msgid "Loading tutorial..." msgstr "Anleitung wird geladen …" +#~ msgid "Category ID" +#~ msgstr "Kategorie-ID" + #~ msgid "Also award a discount stamp when the customer uses this reward" #~ msgstr "Auch bei Nutzung dieser Prämie einen Rabattstempel vergeben" diff --git a/packages/taler-merchant-webui/src/i18n/de.po b/packages/taler-merchant-webui/src/i18n/de.po @@ -5056,10 +5056,6 @@ msgstr "%1$s Produkte" msgid "Category Name" msgstr "Kategoriename" -#: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:692 -msgid "Category ID" -msgstr "Kategorie-ID" - #: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:745 msgid "Rename Category" msgstr "Kategorie umbenennen" @@ -8238,6 +8234,10 @@ msgstr "Nicht verfügbar — vor dem Speichern entfernen" msgid "Remove %1$s (%2$s)" msgstr "%1$s (%2$s) entfernen" +#: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:296 +msgid "Remove %1$s" +msgstr "%1$s entfernen" + #: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:444 msgid "No matches" msgstr "Keine Treffer" @@ -15175,6 +15175,9 @@ msgstr "Storybook wird geladen …" msgid "Loading tutorial..." msgstr "Anleitung wird geladen …" +#~ msgid "Category ID" +#~ msgstr "Kategorie-ID" + #~ msgid "Also award a discount stamp when the customer uses this reward" #~ msgstr "Auch bei Nutzung dieser Prämie einen Rabattstempel vergeben" diff --git a/packages/taler-merchant-webui/src/i18n/fr.po b/packages/taler-merchant-webui/src/i18n/fr.po @@ -5024,10 +5024,6 @@ msgstr "%1$s produits" msgid "Category Name" msgstr "Nom de la catégorie" -#: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:692 -msgid "Category ID" -msgstr "Identifiant de catégorie" - #: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:745 msgid "Rename Category" msgstr "Renommer la catégorie" @@ -8195,6 +8191,10 @@ msgstr "Indisponible — à retirer avant l’enregistrement" msgid "Remove %1$s (%2$s)" msgstr "Retirer %1$s (%2$s)" +#: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:296 +msgid "Remove %1$s" +msgstr "Retirer %1$s" + #: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:444 msgid "No matches" msgstr "Aucun résultat" @@ -15135,6 +15135,9 @@ msgstr "Chargement de Storybook..." msgid "Loading tutorial..." msgstr "Chargement du tutoriel..." +#~ msgid "Category ID" +#~ msgstr "Identifiant de catégorie" + #~ msgid "Also award a discount stamp when the customer uses this reward" #~ msgstr "" #~ "Attribuer aussi un tampon de réduction lorsque le client utilise cette " diff --git a/packages/taler-merchant-webui/src/i18n/it.po b/packages/taler-merchant-webui/src/i18n/it.po @@ -5005,10 +5005,6 @@ msgstr "%1$s prodotti" msgid "Category Name" msgstr "Nome della categoria" -#: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:692 -msgid "Category ID" -msgstr "Identificativo categoria" - #: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:745 msgid "Rename Category" msgstr "Rinomina la categoria" @@ -8169,6 +8165,10 @@ msgstr "Non disponibile — rimuovere prima di salvare" msgid "Remove %1$s (%2$s)" msgstr "Rimuovi %1$s (%2$s)" +#: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:296 +msgid "Remove %1$s" +msgstr "Rimuovi %1$s" + #: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:444 msgid "No matches" msgstr "Nessun risultato" @@ -15074,6 +15074,9 @@ msgstr "Caricamento di Storybook..." msgid "Loading tutorial..." msgstr "Caricamento dell’esercitazione..." +#~ msgid "Category ID" +#~ msgstr "Identificativo categoria" + #~ msgid "Also award a discount stamp when the customer uses this reward" #~ msgstr "" #~ "Assegna un timbro sconto anche quando il cliente utilizza questo premio" diff --git a/packages/taler-merchant-webui/src/i18n/strings.ts b/packages/taler-merchant-webui/src/i18n/strings.ts @@ -1869,7 +1869,6 @@ strings["it"] = { "1 product": ["1 prodotto"], "%1$s products": ["%1$s prodotti"], "Category Name": ["Nome della categoria"], - "Category ID": ["Identificativo categoria"], "Rename Category": ["Rinomina la categoria"], "Add a Category": ["Aggiungi una categoria"], "e.g. Beverages": ["ad es. Bevande"], @@ -3132,6 +3131,7 @@ strings["it"] = { "Non disponibile — rimuovere prima di salvare", ], "Remove %1$s (%2$s)": ["Rimuovi %1$s (%2$s)"], + "Remove %1$s": ["Rimuovi %1$s"], "No matches": ["Nessun risultato"], "Creating category…": ["Creazione della categoria…"], "Create category “%1$s”": ["Crea la categoria «%1$s»"], @@ -7894,7 +7894,6 @@ strings["fr"] = { "1 product": ["1 produit"], "%1$s products": ["%1$s produits"], "Category Name": ["Nom de la catégorie"], - "Category ID": ["Identifiant de catégorie"], "Rename Category": ["Renommer la catégorie"], "Add a Category": ["Ajouter une catégorie"], "e.g. Beverages": ["p. ex. Boissons"], @@ -9173,6 +9172,7 @@ strings["fr"] = { "Indisponible — à retirer avant l’enregistrement", ], "Remove %1$s (%2$s)": ["Retirer %1$s (%2$s)"], + "Remove %1$s": ["Retirer %1$s"], "No matches": ["Aucun résultat"], "Creating category…": ["Création de la catégorie…"], "Create category “%1$s”": ["Créer la catégorie « %1$s »"], @@ -13957,7 +13957,6 @@ strings["de"] = { "1 product": ["1 Produkt"], "%1$s products": ["%1$s Produkte"], "Category Name": ["Kategoriename"], - "Category ID": ["Kategorie-ID"], "Rename Category": ["Kategorie umbenennen"], "Add a Category": ["Kategorie hinzufügen"], "e.g. Beverages": ["z. B. Getränke"], @@ -15236,6 +15235,7 @@ strings["de"] = { "Nicht verfügbar — vor dem Speichern entfernen", ], "Remove %1$s (%2$s)": ["%1$s (%2$s) entfernen"], + "Remove %1$s": ["%1$s entfernen"], "No matches": ["Keine Treffer"], "Creating category…": ["Kategorie wird erstellt…"], "Create category “%1$s”": ["Kategorie „%1$s“ erstellen"], @@ -19976,7 +19976,6 @@ strings["de-CH"] = { "1 product": ["1 Produkt"], "%1$s products": ["%1$s Produkte"], "Category Name": ["Kategoriename"], - "Category ID": ["Kategorie-ID"], "Rename Category": ["Kategorie umbenennen"], "Add a Category": ["Kategorie hinzufügen"], "e.g. Beverages": ["z. B. Getränke"], @@ -21255,6 +21254,7 @@ strings["de-CH"] = { "Nicht verfügbar — vor dem Speichern entfernen", ], "Remove %1$s (%2$s)": ["%1$s (%2$s) entfernen"], + "Remove %1$s": ["%1$s entfernen"], "No matches": ["Keine Treffer"], "Creating category…": ["Kategorie wird erstellt…"], "Create category “%1$s”": ["Kategorie «%1$s» erstellen"], diff --git a/packages/taler-merchant-webui/src/i18n/taler-merchant-webui.pot b/packages/taler-merchant-webui/src/i18n/taler-merchant-webui.pot @@ -4776,10 +4776,6 @@ msgstr "" msgid "Category Name" msgstr "" -#: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:692 -msgid "Category ID" -msgstr "" - #: packages/taler-merchant-webui/src/screens/InventoryScreen.tsx:745 msgid "Rename Category" msgstr "" @@ -7734,6 +7730,10 @@ msgstr "" msgid "Remove %1$s (%2$s)" msgstr "" +#: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:296 +msgid "Remove %1$s" +msgstr "" + #: packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx:444 msgid "No matches" msgstr "" diff --git a/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateSubscriptionScreen.tsx @@ -786,6 +786,7 @@ function CreateSubscriptionForm({ loading={categoriesInitiallyLoading} unavailable={categoriesUnavailable} emptyMessage={emptyMessage} + showIds={false} label={t`Search categories`} addLabel={t`+ Add product category`} /> diff --git a/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx b/packages/taler-merchant-webui/src/screens/InventoryScreen.tsx @@ -487,7 +487,7 @@ export function InventoryScreen({ : found.name, ); } else { - resolvedCats.push(`Cat #${catId}`); + resolvedCats.push(t`Unavailable`); } } }); @@ -655,9 +655,6 @@ export function InventoryScreen({ <div class="flex items-start justify-between gap-3"> <div class="min-w-0"> <h2 class="font-semibold text-gray-900">{cat.name}</h2> - <p class="mt-1 break-all font-mono text-xs text-gray-500"> - {cat.id} - </p> </div> <RowActions label={t`Actions for ${cat.name}`}> <button @@ -689,7 +686,6 @@ export function InventoryScreen({ <thead class="bg-gray-50 text-gray-500 font-semibold"> <tr> <th class="px-4 py-3 text-left">{t`Category Name`}</th> - <th class="px-4 py-3 text-left">{t`Category ID`}</th> <th class="px-4 py-3 text-left">{t`Products Count`}</th> <th class="w-14 px-3 py-3 text-right"> <span class="sr-only">{t`Actions`}</span> @@ -702,9 +698,6 @@ export function InventoryScreen({ <td class="px-4 py-3 font-semibold text-gray-900"> {cat.name} </td> - <td class="px-4 py-3 font-mono text-xs text-gray-500"> - {cat.id} - </td> <td class="px-4 py-3 text-xs text-gray-700 font-medium"> {cat.productCount === 1 ? t`1 product` diff --git a/packages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx b/packages/taler-merchant-webui/src/screens/InventoryTemplateFields.tsx @@ -60,13 +60,13 @@ export function InventoryTemplateFields({ productChoices.push({ value: id, label: id as TranslatedString }); const categoryChoices = categories.map((c) => ({ value: String(c.id), - label: `${c.name} (${c.id})` as TranslatedString, + label: c.name as TranslatedString, })); for (const id of value.categories) if (!categories.some((c) => c.id === id)) categoryChoices.push({ value: String(id), - label: String(id) as TranslatedString, + label: t`Unavailable` as TranslatedString, }); return ( <section diff --git a/packages/taler-merchant-webui/src/screens/OrderDetailScreen.tsx b/packages/taler-merchant-webui/src/screens/OrderDetailScreen.tsx @@ -596,12 +596,14 @@ export function OrderDetailScreen({ <p class="text-sm text-gray-700 mt-1">{t`Open Taler Wallet and scan this payment code.`}</p> </div> - <p class="text-xs text-gray-600"> - <strong>{t`Payment deadline:`}</strong>{" "} - {order.payDeadline - ? AbsoluteTime.formatTimestamp(order.payDeadline) - : t`Unavailable`} - </p> + {order.payDeadline && ( + <p class="text-xs text-gray-600"> + <strong>{t`Payment deadline:`}</strong>{" "} + {order.payDeadline.t_ms === "never" + ? t`No deadline` + : AbsoluteTime.formatTimestamp(order.payDeadline)} + </p> + )} <div class="pt-1"> <button @@ -699,7 +701,9 @@ export function OrderDetailScreen({ <div class="flex justify-between gap-4 py-3 border-t border-gray-100"> <dt class="text-gray-500">{t`Payment deadline`}</dt> <dd class="font-medium text-gray-900 text-right"> - {AbsoluteTime.formatTimestamp(order.payDeadline, "—")} + {order.payDeadline.t_ms === "never" + ? t`No deadline` + : AbsoluteTime.formatTimestamp(order.payDeadline, "—")} </dd> </div> )} diff --git a/packages/taler-merchant-webui/src/screens/order-detail/OrderTimeline.tsx b/packages/taler-merchant-webui/src/screens/order-detail/OrderTimeline.tsx @@ -92,7 +92,7 @@ export function buildOrderTimeline( } // 3. Payment Deadline / Expired - if (!paidDate && order.payDeadline) { + if (!paidDate && order.payDeadline && order.payDeadline.t_ms !== "never") { const payDlDate = order.payDeadline; if (payDlDate) { if (order.status === "offered") { diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -1263,6 +1263,39 @@ const DETAIL_CHOICE_BASE = { paymentChoices: DETAIL_CHOICE_FIXTURES, }; +test("OrderDetailScreen distinguishes missing, finite, and unlimited payment deadlines", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + try { + for (const payDeadline of [ + undefined, + AbsoluteTime.never(), + DETAIL_CHOICE_BASE.payDeadline, + ]) { + render( + <OrderDetailScreen order={{ ...DETAIL_CHOICE_BASE, payDeadline }} />, + container, + ); + const text = container.textContent ?? ""; + assert.doesNotMatch(text, /Payment deadline:\s*Unavailable|N\/A/); + assert.ok( + container.querySelector("[data-taler-qr-code]") ?? + container.querySelector("svg"), + ); + if (!payDeadline) assert.doesNotMatch(text, /Payment deadline/); + else if (payDeadline.t_ms === "never") { + assert.match(text, /Payment deadline:\s*No deadline/); + assert.doesNotMatch(text, /Latest time for customer/); + } else { + assert.ok(text.includes(AbsoluteTime.formatTimestamp(payDeadline))); + } + } + } finally { + render(null, container); + container.remove(); + } +}); + test("OrderDetailScreen shows pending v1 choices and their effects before the QR", () => { const container = document.createElement("div"); document.body.appendChild(container); @@ -3582,7 +3615,7 @@ test("CreateSubscriptionScreen saves nested semantics and preserves unrelated ex ).value, "0.05", ); - assert.ok(container.querySelector('button[aria-label="Remove Drinks (1)"]')); + assert.ok(container.querySelector('button[aria-label="Remove Drinks"]')); const form = container.querySelector("form") as HTMLFormElement; assert.ok(form); @@ -4031,7 +4064,7 @@ test("CreateSubscriptionScreen retains unavailable snapshot names and blocks sav ); assert.match( container.textContent ?? "", - /Deleted coffee1Unavailable — remove before saving/, + /Deleted coffeeUnavailable — remove before saving/, ); await act(async () => { container diff --git a/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx b/packages/taler-merchant-webui/src/screens/token-setup/TokenSelector.tsx @@ -28,6 +28,7 @@ export function TokenSelector<T extends { id: string | number; name: string }>({ emptyMessage, label, addLabel, + showIds = true, }: { selected: T[]; onChange: (value: T[]) => void; @@ -39,6 +40,7 @@ export function TokenSelector<T extends { id: string | number; name: string }>({ emptyMessage: string; label: string; addLabel: string; + showIds?: boolean; }) { const { t } = useTranslation(); const id = useId(); @@ -74,7 +76,9 @@ export function TokenSelector<T extends { id: string | number; name: string }>({ loading || unavailable ? [] : options.filter((entry) => - `${entry.name} ${entry.id}`.toLocaleLowerCase().includes(query), + `${entry.name} ${showIds ? entry.id : ""}` + .toLocaleLowerCase() + .includes(query), ); const close = useCallback(() => { setOpen(false); @@ -277,14 +281,20 @@ export function TokenSelector<T extends { id: string | number; name: string }>({ > <span class="min-w-0 break-words"> {entry.name} - <span class="block text-xs text-gray-500">{entry.id}</span> + {showIds && ( + <span class="block text-xs text-gray-500">{entry.id}</span> + )} {missing && ( <span class="block text-xs text-amber-800">{t`Unavailable — remove before saving`}</span> )} </span> <button type="button" - aria-label={t`Remove ${entry.name} (${entry.id})`} + aria-label={ + showIds + ? t`Remove ${entry.name} (${entry.id})` + : t`Remove ${entry.name}` + } class="shrink-0 rounded p-2 text-gray-600 hover:bg-blue-100 focus:ring-2 focus:ring-blue-500" onClick={() => { const buttons = @@ -427,9 +437,11 @@ export function TokenSelector<T extends { id: string | number; name: string }>({ /> <span class="min-w-0 break-words"> {entry.name} - <span class="block text-xs text-gray-500"> - {entry.id} - </span> + {showIds && ( + <span class="block text-xs text-gray-500"> + {entry.id} + </span> + )} </span> </div> ); diff --git a/packages/taler-merchant-webui/src/screens/token-setup/wizard.test.tsx b/packages/taler-merchant-webui/src/screens/token-setup/wizard.test.tsx @@ -646,7 +646,7 @@ for (const setupMode of ["wizard", "classic"] as const) { true, ); check(ui.container, 'input[name="discount_type"][value="free_item"]'); - assert.ok(ui.container.querySelector('[aria-label="Remove Coffee (1)"]')); + assert.ok(ui.container.querySelector('[aria-label="Remove Coffee"]')); await submit(ui.container); if (setupMode === "wizard") { assert.match(current(ui.container)!, /Validity/); @@ -1051,7 +1051,7 @@ test("inline category creation selects the new category for earning and makes it button(ui.container, "Create category “Desserts”").click(), ); assert.deepEqual(names, ["Desserts"]); - assert.ok(ui.container.querySelector('[aria-label="Remove Desserts (9)"]')); + assert.ok(ui.container.querySelector('[aria-label="Remove Desserts"]')); assert.ok(ui.container.querySelector('[role="dialog"]')); assert.equal(ui.container.querySelector("#issuance-products-error"), null); assert.equal(ui.container.querySelector('[role="alert"]'), null); @@ -1399,10 +1399,10 @@ test("product scope choices are independent and preserve selected categories", ( } act(() => button(earning, "+ Add product category").click()); act(() => earning.querySelector<HTMLElement>('[role="option"]')!.click()); - assert.ok(earning.querySelector('[aria-label="Remove Coffee (1)"]')); + assert.ok(earning.querySelector('[aria-label="Remove Coffee"]')); check(earning, 'input[aria-label="All purchases"]'); assert.equal( - earning.querySelector('[aria-label="Remove Coffee (1)"]'), + earning.querySelector('[aria-label="Remove Coffee"]'), null, ); assert.equal( @@ -1411,7 +1411,7 @@ test("product scope choices are independent and preserve selected categories", ( true, ); check(earning, 'input[aria-label="Purchases with matching products"]'); - assert.ok(earning.querySelector('[aria-label="Remove Coffee (1)"]')); + assert.ok(earning.querySelector('[aria-label="Remove Coffee"]')); assert.equal( earning.querySelector<HTMLInputElement>( 'input[aria-label="All purchases"]', diff --git a/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx b/packages/taler-merchant-webui/src/stories/TokenSelectorStory.tsx @@ -95,6 +95,7 @@ export function TokenSelectorStory({ loading={loading} unavailable={unavailable} emptyMessage={t`No product categories are available. Create a category or select an individual product.`} + showIds={false} label={t`Search categories`} addLabel={t`+ Add product category`} />