From aaa4c9d8dce6b51e929c59bbad599343f8dbbd9d Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:06:15 +0100 Subject: [PATCH 01/86] Add border to Order summary and remove bg --- src/orders/components/OrderSummary/OrderValue.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 4cc7cf4f0a1..8eb8cb12d64 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -32,11 +32,10 @@ export const OrderValue = ({ return ( From 7ce12ba3718af6767b32d51b93fa5e072e51c925 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:28:06 +0100 Subject: [PATCH 02/86] Improve empty state for Payments summary card --- .../OrderSummary/PaymentsSummary.tsx | 10 ++---- .../PaymentsSummaryEmptyState.module.css | 26 ++++++++++++++++ .../PaymentsSummaryEmptyState.tsx | 31 +++++++++++++++++++ .../OrderSummary/PaymentsSummaryHeader.tsx | 10 +++--- 4 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css create mode 100644 src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx diff --git a/src/orders/components/OrderSummary/PaymentsSummary.tsx b/src/orders/components/OrderSummary/PaymentsSummary.tsx index 006adadae8d..1274d24df31 100644 --- a/src/orders/components/OrderSummary/PaymentsSummary.tsx +++ b/src/orders/components/OrderSummary/PaymentsSummary.tsx @@ -5,6 +5,7 @@ import { useIntl } from "react-intl"; import { OrderAuthorizeStatusBadge } from "./OrderAuthorizeStatusBadge"; import { OrderChargeStatusBadge } from "./OrderChargeStatusBadge"; import { OrderSummaryListItem } from "./OrderSummaryListItem"; +import { PaymentsSummaryEmptyState } from "./PaymentsSummaryEmptyState"; import { PaymentsSummaryHeader } from "./PaymentsSummaryHeader"; type Props = PropsWithBox<{ @@ -31,13 +32,8 @@ export const PaymentsSummary = ({ orderAmounts, order, hasNoPayment, ...props }: borderWidth={1} {...props} > - + + ); } diff --git a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css new file mode 100644 index 00000000000..513aaa5996c --- /dev/null +++ b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css @@ -0,0 +1,26 @@ +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px 16px; + gap: 0; +} + +.iconWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background-color: var(--macaw-color-background-default2); +} + +.textContainer { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-align: center; +} diff --git a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx new file mode 100644 index 00000000000..4f80454f6a9 --- /dev/null +++ b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx @@ -0,0 +1,31 @@ +import { Box, Text } from "@saleor/macaw-ui-next"; +import { CreditCard } from "lucide-react"; +import { useIntl } from "react-intl"; + +import styles from "./PaymentsSummaryEmptyState.module.css"; + +export const PaymentsSummaryEmptyState = () => { + const intl = useIntl(); + + return ( + + + + + + + {intl.formatMessage({ + defaultMessage: "No payment received", + id: "6Jgwpc", + })} + + + {intl.formatMessage({ + defaultMessage: "Mark as paid manually if the payment is confirmed", + id: "3Eyq0y", + })} + + + + ); +}; diff --git a/src/orders/components/OrderSummary/PaymentsSummaryHeader.tsx b/src/orders/components/OrderSummary/PaymentsSummaryHeader.tsx index 7b4902c3d1f..b0a88658242 100644 --- a/src/orders/components/OrderSummary/PaymentsSummaryHeader.tsx +++ b/src/orders/components/OrderSummary/PaymentsSummaryHeader.tsx @@ -4,7 +4,7 @@ import { useIntl } from "react-intl"; type Props = { order: OrderDetailsFragment; - description: string; + description?: string; }; export const PaymentsSummaryHeader = ({ description }: Props) => { @@ -19,9 +19,11 @@ export const PaymentsSummaryHeader = ({ description }: Props) => { id: "q7bXR4", })} - - {description} - + {description && ( + + {description} + + )} ); From 5c1b09d10908a93602a004f5d42f2daa90336191 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:46:03 +0100 Subject: [PATCH 03/86] Match font size and add tabular nums to amounts --- .../components/OrderSummary/OrderSummaryListAmount.tsx | 7 ++++++- .../components/OrderSummary/OrderSummaryListItem.tsx | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx index e3e2687477a..ccf1cc2e76a 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx @@ -1,4 +1,5 @@ import { Text, TextProps } from "@saleor/macaw-ui-next"; +import React from "react"; import { useIntl } from "react-intl"; type Props = Omit & { @@ -6,11 +7,15 @@ type Props = Omit & { showSign?: boolean; }; +const tabularNumsStyle: React.CSSProperties = { + fontVariantNumeric: "tabular-nums", +}; + export const OrderSummaryListAmount = ({ amount, showSign = false, ...props }: Props) => { const intl = useIntl(); return ( - + {intl.formatNumber(amount, { minimumFractionDigits: 2, signDisplay: showSign ? "exceptZero" : "auto", diff --git a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx index 5cf407f0e8f..6b759963070 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx @@ -14,14 +14,14 @@ type Props = PropsWithBox<{ export const OrderSummaryListItem = ({ children, amount, showSign, currency, ...props }: Props) => { return ( - + {children} - + {currency} {" "} - + ); From 51ae25d58b353bc6208aa7fe7be7bdf01816d57b Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:49:34 +0100 Subject: [PATCH 04/86] Fix buttons stacking on each other --- .../OrderSummary/LegacyPaymentsApiButtons.tsx | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index b8ee281b768..0a974c3b9ca 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -27,49 +27,51 @@ export const LegacyPaymentsApiButtons = ({ }: Props) => { const intl = useIntl(); + const showButtons = + order?.status !== OrderStatus.CANCELED && (canCapture || canRefund || canVoid || canMarkAsPaid); + + if (!showButtons) { + return null; + } + return ( - - {order?.status !== OrderStatus.CANCELED && - (canCapture || canRefund || canVoid || canMarkAsPaid) && ( - <> - {canCapture && ( - - )} - {canRefund && ( - - )} - {canVoid && ( - - )} - {canMarkAsPaid && ( - - )} - - )} + + {canCapture && ( + + )} + {canRefund && ( + + )} + {canVoid && ( + + )} + {canMarkAsPaid && ( + + )} ); }; From 7c2b4d6ad9b405389d275dd8e2c3ca8bafa209bf Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:51:27 +0100 Subject: [PATCH 05/86] Extract messages --- locale/defaultMessages.json | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index e4c59755964..6d645ebd4df 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -834,6 +834,9 @@ "3DGvA/": { "string": "Remember this will also unpin all products assigned to this category, making them unavailable in storefront." }, + "3Eyq0y": { + "string": "Mark as paid manually if the payment is confirmed" + }, "3PVGWj": { "string": "Filter preset" }, @@ -1314,6 +1317,9 @@ "6J1m2c": { "string": "Create a Model" }, + "6Jgwpc": { + "string": "No payment received" + }, "6QjMei": { "string": "Preorder end time needs to be set in the future" }, @@ -2694,9 +2700,6 @@ "context": "postal codes, header", "string": "Postal codes" }, - "Fcxl/G": { - "string": "This order has no payment yet" - }, "FemBUF": { "context": "header", "string": "Translations to {language}" From d1f52636369e6e4ad786a0a6113b2ff51e5a0a77 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:52:35 +0100 Subject: [PATCH 06/86] Add changeset --- .changeset/deep-ducks-sniff.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/deep-ducks-sniff.md diff --git a/.changeset/deep-ducks-sniff.md b/.changeset/deep-ducks-sniff.md new file mode 100644 index 00000000000..642e6dffc9c --- /dev/null +++ b/.changeset/deep-ducks-sniff.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Improve Order summary section From 3390702932cd8d2d6ceab765dc04235b6b5ff283 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 5 Dec 2025 18:53:06 +0100 Subject: [PATCH 07/86] Fix linting warning --- src/orders/components/OrderSummary/OrderSummaryListAmount.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx index ccf1cc2e76a..64a754526ea 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx @@ -1,5 +1,5 @@ import { Text, TextProps } from "@saleor/macaw-ui-next"; -import React from "react"; +import { CSSProperties } from "react"; import { useIntl } from "react-intl"; type Props = Omit & { @@ -7,7 +7,7 @@ type Props = Omit & { showSign?: boolean; }; -const tabularNumsStyle: React.CSSProperties = { +const tabularNumsStyle: CSSProperties = { fontVariantNumeric: "tabular-nums", }; From 5ba1c1577457022e7efbc88fa4b287a4a8f99f95 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 14:11:53 +0100 Subject: [PATCH 08/86] Fix "No applicable shipping methods" bug --- locale/defaultMessages.json | 4 ++++ .../OrderDraftDetailsSummary.tsx | 21 +++++++++++++++++-- .../OrderDraftDetailsSummary/messages.ts | 5 +++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index e4c59755964..c839d3540c8 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -6576,6 +6576,10 @@ "context": "vat included in order price", "string": "VAT included" }, + "dJv+cZ": { + "context": "shown when shipping method is selected but no other options exist", + "string": "no alternatives available" + }, "dLIeRH": { "context": "charge status none", "string": "None" diff --git a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx b/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx index 14a83701688..4c7c99a29d4 100644 --- a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx +++ b/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx @@ -121,6 +121,21 @@ const OrderDraftDetailsSummary = (props: OrderDraftDetailsSummaryProps) => { }; const getShippingMethodComponent = () => { if (hasChosenShippingMethod) { + // Shipping method is selected but no alternatives available - show as plain text + if (!hasShippingMethods) { + return ( +
+ + {shippingMethodName} + + + + {`(${intl.formatMessage(messages.noAlternativeShippingMethods)})`} + +
+ ); + } + return {`${shippingMethodName}`}; } @@ -193,9 +208,11 @@ const OrderDraftDetailsSummary = (props: OrderDraftDetailsSummaryProps) => { - {hasShippingMethods && getShippingMethodComponent()} + {(hasShippingMethods || hasChosenShippingMethod) && getShippingMethodComponent()} - {!hasShippingMethods && intl.formatMessage(messages.noShippingCarriers)} + {!hasShippingMethods && + !hasChosenShippingMethod && + intl.formatMessage(messages.noShippingCarriers)} {formErrors.shipping && ( diff --git a/src/orders/components/OrderDraftDetailsSummary/messages.ts b/src/orders/components/OrderDraftDetailsSummary/messages.ts index 89b7bc07dba..0aaa018a85c 100644 --- a/src/orders/components/OrderDraftDetailsSummary/messages.ts +++ b/src/orders/components/OrderDraftDetailsSummary/messages.ts @@ -31,6 +31,11 @@ export const messages = defineMessages({ defaultMessage: "No applicable shipping carriers", description: "no shipping carriers title", }, + noAlternativeShippingMethods: { + id: "8EDRSz", + defaultMessage: "no alternatives available", + description: "shown when shipping method is selected but no other options exist", + }, total: { id: "S/yAtJ", defaultMessage: "Total", From 2f242bf28501c486992a55942a7a33e108a9f204 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 15:39:15 +0100 Subject: [PATCH 09/86] Fix Edit Shipping Options modal to show unavailable method so user is not confused when the name matches --- .../OrderShippingMethodEditDialog.tsx | 175 ++++++++++++------ .../OrderDetails/OrderDraftDetails/index.tsx | 2 + .../OrderUnconfirmedDetails/index.tsx | 2 + 3 files changed, 118 insertions(+), 61 deletions(-) diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx index 1d6456966b3..8c6f887f6bb 100644 --- a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx @@ -23,6 +23,8 @@ interface OrderShippingMethodEditDialogProps { errors: OrderErrorFragment[]; open: boolean; shippingMethod: string; + shippingMethodName?: string; + shippingPrice?: OrderDetailsFragment["shippingPrice"]; shippingMethods?: OrderDetailsFragment["shippingMethods"]; onClose: () => any; onSubmit?: (data: FormData) => any; @@ -34,6 +36,8 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps errors: apiErrors, open, shippingMethod, + shippingMethodName, + shippingPrice, shippingMethods, onClose, onSubmit, @@ -43,7 +47,7 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps const formFields = ["shippingMethod"]; const formErrors = getFormErrors(formFields, errors); const nonFieldErrors = errors.filter(err => !formFields.includes(err.field)); - const choices = shippingMethods + const availableChoices = shippingMethods ? shippingMethods .map(s => ({ label: ( @@ -68,73 +72,122 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps })) .sort((x, y) => (x.disabled === y.disabled ? 0 : x.disabled ? 1 : -1)) : []; + + const currentMethodInChoices = availableChoices.some(c => c.value === shippingMethod); + + const unavailableMethodOption = + shippingMethod && !currentMethodInChoices + ? [ + { + label: ( + + + + {shippingMethodName || ( + + )} + + + + {shippingPrice && ( + + + + + + )} + + + + + + ), + disabled: true, + value: shippingMethod, + }, + ] + : []; + + const choices = [...unavailableMethodOption, ...availableChoices]; + const initialForm: FormData = { - shippingMethod, + shippingMethod: currentMethodInChoices ? shippingMethod : "", }; return ( -
- {({ change, data, submit }) => ( - <> - - - + {({ change, data, submit }) => ( + <> + + + + + + { - const value = target.value; - const isDisabled = choices.find(({ value }) => value === value)?.disabled; - - if (isDisabled) { - return; - } - - change({ - target: { - name: "shippingMethod", - value: typeof value === "string" ? value : (value as Option)?.value, - }, - }); - }} - /> - {nonFieldErrors.length > 0 && ( - <> - - {nonFieldErrors.map((err, index) => ( - - {getOrderErrorMessage(err, intl)} - - ))} - - )} + {nonFieldErrors.length > 0 && ( + <> + + {nonFieldErrors.map((err, index) => ( + + {getOrderErrorMessage(err, intl)} + + ))} + + )} - - - - - - - - - )} - + + + + + + +
+ + )} + + )}
); }; diff --git a/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx b/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx index de5f28c4a75..ddcd9a51680 100644 --- a/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx @@ -259,6 +259,8 @@ export const OrderDraftDetails = ({ errors={orderShippingMethodUpdate.opts.data?.orderUpdateShipping.errors || []} open={params.action === "edit-shipping"} shippingMethod={order?.shippingMethod?.id} + shippingMethodName={order?.shippingMethodName} + shippingPrice={order?.shippingPrice} shippingMethods={order?.shippingMethods} onClose={closeModal} onSubmit={variables => diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index 0357098382e..c5fab507284 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -316,6 +316,8 @@ export const OrderUnconfirmedDetails = ({ errors={orderShippingMethodUpdate.opts.data?.orderUpdateShipping.errors || []} open={params.action === "edit-shipping"} shippingMethod={order?.shippingMethod?.id} + shippingMethodName={order?.shippingMethodName} + shippingPrice={order?.shippingPrice} shippingMethods={order?.shippingMethods} onClose={closeModal} onSubmit={variables => From 7dcf708c98dc7bbaf492044f2bde85f88bb988a0 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 16:35:09 +0100 Subject: [PATCH 10/86] Merge the old UNCONFIRMED and DRAFT order summary into the new design "Order value" --- locale/defaultMessages.json | 64 ++- .../OrderDetailsPage/OrderDetailsPage.tsx | 36 +- .../OrderDraftDetails/OrderDraftDetails.tsx | 5 +- .../OrderDraftDetailsSummary.tsx | 18 +- .../OrderDraftDetailsSummary/messages.ts | 6 +- .../OrderDraftPage/OrderDraftPage.tsx | 20 + .../OrderSummary/OrderSummary.test.tsx | 4 +- .../components/OrderSummary/OrderSummary.tsx | 111 +++- .../OrderSummary/OrderSummaryListItem.tsx | 16 +- .../components/OrderSummary/OrderValue.tsx | 516 +++++++++++++++--- 10 files changed, 664 insertions(+), 132 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index c839d3540c8..3946c6861c7 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -519,6 +519,10 @@ "context": "dialog header", "string": "Change customer shipping address" }, + "12K83x": { + "context": "shipping method option when name unknown", + "string": "Current method" + }, "15PiOX": { "context": "button title", "string": "Unassign" @@ -1067,6 +1071,10 @@ "context": "account information, header", "string": "Account Information" }, + "4xZFP4": { + "context": "tooltip for shipping amount", + "string": "Shipping cost" + }, "4y3Smi": { "string": "Removing permissions may cause extension to break." }, @@ -1561,6 +1569,10 @@ "context": "checkbox label", "string": "Send fulfillment email to customer" }, + "8EDRSz": { + "context": "tooltip shown when shipping method is selected but no other options exist", + "string": "No alternative shipping methods available" + }, "8EGagh": { "context": "search box label", "string": "Filter Countries" @@ -2873,6 +2885,10 @@ "context": "dialog header", "string": "Select destination channel:" }, + "GSC7Rw": { + "context": "tooltip for taxes amount", + "string": "Tax amount" + }, "GTCg9O": { "string": "You must add at least one voucher code" }, @@ -2938,6 +2954,10 @@ "context": "add metadata field,button", "string": "Add Field" }, + "GiJ1QZ": { + "context": "tooltip for total amount", + "string": "Order total" + }, "GiJm1v": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to unassign this category?} other{Are you sure you want to unassign {displayQuantity} categories?}}" @@ -3273,6 +3293,10 @@ "context": "header", "string": "Edit Media" }, + "IirEpN": { + "context": "tooltip for taxes when showing gross prices", + "string": "Gross prices" + }, "Ila7WO": { "context": "change warehouse dialog description", "string": "Choose warehouse from which you want to fulfill {productName}" @@ -3310,6 +3334,10 @@ "context": "bulk disable label", "string": "Deactivate" }, + "IzrVyw": { + "context": "tooltip for taxes when showing net prices", + "string": "Net prices" + }, "J/QqOI": { "string": "This value already exists within this attribute" }, @@ -3491,6 +3519,10 @@ "context": "refund button", "string": "Refund" }, + "K/a8rS": { + "context": "tooltip for subtotal amount", + "string": "Sum of all line items" + }, "K/gnGg": { "string": "If you want to disable this User please uncheck the box below." }, @@ -4290,6 +4322,10 @@ "context": "back button", "string": "Back" }, + "PRlD0A": { + "context": "shipping label", + "string": "Shipping" + }, "PTW56s": { "context": "alert", "string": "Channel limit reached" @@ -4747,7 +4783,7 @@ "string": "Conditions" }, "SB//YQ": { - "string": "Order summary" + "string": "Summary" }, "SBb6Ej": { "context": "select a warehouse to fulfill product from", @@ -5524,6 +5560,10 @@ "context": "successfully created gift card alert title", "string": "Successfully created gift card" }, + "X1NCdA": { + "context": "shipping method not available label", + "string": "(not available)" + }, "X6PF8z": { "context": "entity (product, collection, shipping method) name", "string": "Name" @@ -5998,6 +6038,10 @@ "context": "note input subtitle", "string": "Why was this gift card issued. This note will not be shown to the customer. Note will be stored in gift card history" }, + "Zvjkx8": { + "context": "tooltip for discount amount", + "string": "Discount amount" + }, "Zvo5iu": { "string": "API reference" }, @@ -6576,10 +6620,6 @@ "context": "vat included in order price", "string": "VAT included" }, - "dJv+cZ": { - "context": "shown when shipping method is selected but no other options exist", - "string": "no alternatives available" - }, "dLIeRH": { "context": "charge status none", "string": "None" @@ -7967,9 +8007,6 @@ "mmcHeH": { "string": "Discount Value" }, - "mpkBZc": { - "string": "Shipping {carrierName}" - }, "mr9jbO": { "string": "Preferred Language" }, @@ -8794,6 +8831,10 @@ "context": "limit voucher", "string": "Limit of Uses" }, + "s6YKKO": { + "context": "set shipping link when no shipping method selected", + "string": "Set shipping" + }, "s6lW8R": { "context": "option label", "string": "Change address" @@ -9426,12 +9467,13 @@ "context": "success notifier message", "string": "Saved draft" }, - "vs0xiH": { - "string": "Discount {discountName}" - }, "vuKrlW": { "string": "Stock" }, + "vuNPLt": { + "context": "tooltip for gift card amount", + "string": "Gift card amount used" + }, "vwA9Fq": { "context": "notification", "string": "Selected models were deleted." diff --git a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx index 1cc276d8aab..8025b84c89b 100644 --- a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx +++ b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx @@ -28,9 +28,11 @@ import { defaultGraphiQLQuery } from "@dashboard/orders/queries"; import { rippleOrderMetadata } from "@dashboard/orders/ripples/orderMetadata"; import { orderShouldUseTransactions } from "@dashboard/orders/types"; import { orderListUrl } from "@dashboard/orders/urls"; +import { OrderDiscountContext } from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; import { Ripple } from "@dashboard/ripples/components/Ripple"; import { Box, Button, Divider } from "@saleor/macaw-ui-next"; import { Code } from "lucide-react"; +import { useContext } from "react"; import { useIntl } from "react-intl"; import { getMutationErrors, maybe } from "../../../misc"; @@ -136,6 +138,7 @@ const OrderDetailsPage = (props: OrderDetailsPageProps) => { } = props; const navigate = useNavigator(); const intl = useIntl(); + const orderDiscountContext = useContext(OrderDiscountContext); const isOrderUnconfirmed = order?.status === OrderStatus.UNCONFIRMED; const canCancel = order?.status !== OrderStatus.CANCELED; const canEditAddresses = order?.status !== OrderStatus.CANCELED; @@ -250,6 +253,7 @@ const OrderDetailsPage = (props: OrderDetailsPageProps) => { onOrderLineChange={onOrderLineChange} onOrderLineRemove={onOrderLineRemove} onShippingMethodEdit={onShippingMethodEdit} + hideSummary /> @@ -269,7 +273,7 @@ const OrderDetailsPage = (props: OrderDetailsPageProps) => { /> ))} - {order && ( + {order && !isOrderUnconfirmed && ( <> { )} + {order && isOrderUnconfirmed && orderDiscountContext && ( + <> + + + + {orderShouldUseTransactions(order) && ( + + )} + + )} + void; onShippingMethodEdit: () => void; onOrderLineShowMetadata: (id: string) => void; + /** Hide the summary section (when using OrderSummary component instead) */ + hideSummary?: boolean; } const OrderDraftDetails = ({ @@ -39,6 +41,7 @@ const OrderDraftDetails = ({ onOrderLineRemove, onShippingMethodEdit, onOrderLineShowMetadata, + hideSummary = false, }: OrderDraftDetailsProps) => { const intl = useIntl(); const isChannelActive = order?.channel.isActive; @@ -70,7 +73,7 @@ const OrderDraftDetails = ({ onOrderLineRemove={onOrderLineRemove} onOrderLineShowMetadata={onOrderLineShowMetadata} /> - {maybe(() => order.lines.length) !== 0 && ( + {!hideSummary && maybe(() => order.lines.length) !== 0 && ( {(orderDiscountProps: OrderDiscountContextConsumerProps) => ( diff --git a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx b/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx index 4c7c99a29d4..9614a8443db 100644 --- a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx +++ b/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx @@ -121,18 +121,16 @@ const OrderDraftDetailsSummary = (props: OrderDraftDetailsSummaryProps) => { }; const getShippingMethodComponent = () => { if (hasChosenShippingMethod) { - // Shipping method is selected but no alternatives available - show as plain text + // Shipping method is selected but no alternatives available - show as plain text with tooltip if (!hasShippingMethods) { return ( -
- - {shippingMethodName} - - - - {`(${intl.formatMessage(messages.noAlternativeShippingMethods)})`} - -
+ + {shippingMethodName} + ); } diff --git a/src/orders/components/OrderDraftDetailsSummary/messages.ts b/src/orders/components/OrderDraftDetailsSummary/messages.ts index 0aaa018a85c..b195c069684 100644 --- a/src/orders/components/OrderDraftDetailsSummary/messages.ts +++ b/src/orders/components/OrderDraftDetailsSummary/messages.ts @@ -32,9 +32,9 @@ export const messages = defineMessages({ description: "no shipping carriers title", }, noAlternativeShippingMethods: { - id: "8EDRSz", - defaultMessage: "no alternatives available", - description: "shown when shipping method is selected but no other options exist", + id: "gCZYcl", + defaultMessage: "No alternative shipping methods available", + description: "tooltip shown when shipping method is selected but no other options exist", }, total: { id: "S/yAtJ", diff --git a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx index b5e1238cb0b..256199f318e 100644 --- a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx +++ b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx @@ -23,13 +23,16 @@ import { SubmitPromise } from "@dashboard/hooks/useForm"; import useNavigator from "@dashboard/hooks/useNavigator"; import OrderChannelSectionCard from "@dashboard/orders/components/OrderChannelSectionCard"; import { orderDraftListUrl } from "@dashboard/orders/urls"; +import { OrderDiscountContext } from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; import { FetchMoreProps, RelayToFlat } from "@dashboard/types"; import { Box, Divider, Skeleton, Text } from "@saleor/macaw-ui-next"; +import { useContext } from "react"; import { useIntl } from "react-intl"; import OrderCustomer, { CustomerEditData } from "../OrderCustomer"; import OrderDraftDetails from "../OrderDraftDetails/OrderDraftDetails"; import OrderHistory, { FormData as HistoryFormData } from "../OrderHistory"; +import { OrderSummary } from "../OrderSummary/OrderSummary"; import OrderDraftAlert from "./OrderDraftAlert"; interface OrderDraftPageProps extends FetchMoreProps { @@ -90,6 +93,7 @@ const OrderDraftPage = (props: OrderDraftPageProps) => { } = props; const navigate = useNavigator(); const intl = useIntl(); + const orderDiscountContext = useContext(OrderDiscountContext); const backLinkUrl = useBackLinkWithState({ path: draftOrderListUrl, }); @@ -149,7 +153,23 @@ const OrderDraftPage = (props: OrderDraftPageProps) => { onOrderLineRemove={onOrderLineRemove} onShippingMethodEdit={onShippingMethodEdit} onOrderLineShowMetadata={onOrderLineShowMetadata} + hideSummary /> + {order && orderDiscountContext && ( + <> + { + // Draft orders cannot be marked as paid + }} + isEditable + onShippingMethodEdit={onShippingMethodEdit} + errors={errors} + {...orderDiscountContext} + /> + + + )} { describe("Basic Rendering", () => { - it("should render order summary title", () => { + it("should render summary title", () => { // Arrange const mockOrder = orderFixture("test-id"); const onMarkAsPaid = jest.fn(); @@ -84,7 +84,7 @@ describe("OrderSummary", () => { ); // Assert - expect(screen.getByText("Order summary")).toBeInTheDocument(); + expect(screen.getByText("Summary")).toBeInTheDocument(); }); }); diff --git a/src/orders/components/OrderSummary/OrderSummary.tsx b/src/orders/components/OrderSummary/OrderSummary.tsx index 46f1bd625e2..c8ba4b9a307 100644 --- a/src/orders/components/OrderSummary/OrderSummary.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.tsx @@ -1,5 +1,6 @@ -import { OrderDetailsFragment } from "@dashboard/graphql"; +import { OrderDetailsFragment, OrderErrorFragment } from "@dashboard/graphql"; import { OrderDetailsViewModel } from "@dashboard/orders/utils/OrderDetailsViewModel"; +import { OrderDiscountContextConsumerProps } from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; import { Box, PropsWithBox, Text } from "@saleor/macaw-ui-next"; import { useIntl } from "react-intl"; @@ -8,26 +9,35 @@ import { OrderValue } from "./OrderValue"; import { PaymentsSummary } from "./PaymentsSummary"; import { TransactionsApiButtons } from "./TransactionsApiButtons"; -type OrderSummaryWithLegacyApi = { - useLegacyPaymentsApi: true; - onLegacyPaymentsApiCapture: () => any; - onLegacyPaymentsApiRefund: () => any; - onLegacyPaymentsApiVoid: () => any; -}; +type EditableOrderSummary = { + isEditable: true; + onShippingMethodEdit: () => void; + errors?: OrderErrorFragment[]; +} & OrderDiscountContextConsumerProps; -type OrderSummaryWithoutLegacyApi = { - useLegacyPaymentsApi?: false; +type ReadOnlyOrderSummary = { + isEditable?: false; }; type Props = PropsWithBox< { order: OrderDetailsFragment; onMarkAsPaid: () => any; - } & (OrderSummaryWithLegacyApi | OrderSummaryWithoutLegacyApi) + useLegacyPaymentsApi?: boolean; + onLegacyPaymentsApiCapture?: () => any; + onLegacyPaymentsApiRefund?: () => any; + onLegacyPaymentsApiVoid?: () => any; + } & (EditableOrderSummary | ReadOnlyOrderSummary) >; export const OrderSummary = (props: Props) => { - const { order, onMarkAsPaid, useLegacyPaymentsApi = false, ...restProps } = props; + const { + order, + onMarkAsPaid, + useLegacyPaymentsApi = false, + isEditable = false, + ...restProps + } = props; const intl = useIntl(); const giftCardsAmount = OrderDetailsViewModel.getGiftCardsAmountUsed({ id: order.id, @@ -47,13 +57,37 @@ export const OrderSummary = (props: Props) => { const canVoid = OrderDetailsViewModel.canOrderVoid(order.actions); const canRefund = OrderDetailsViewModel.canOrderRefund(order.actions); + // Extract editable props + const editableProps = isEditable ? (props as Props & EditableOrderSummary) : null; + + // Filter out props that shouldn't be passed to the DOM + const { + isEditable: _isEditable, + onShippingMethodEdit: _onShippingMethodEdit, + errors: _errors, + orderDiscount: _orderDiscount, + addOrderDiscount: _addOrderDiscount, + removeOrderDiscount: _removeOrderDiscount, + openDialog: _openDialog, + closeDialog: _closeDialog, + isDialogOpen: _isDialogOpen, + orderDiscountAddStatus: _orderDiscountAddStatus, + orderDiscountRemoveStatus: _orderDiscountRemoveStatus, + undiscountedPrice: _undiscountedPrice, + discountedPrice: _discountedPrice, + onLegacyPaymentsApiCapture: _onLegacyPaymentsApiCapture, + onLegacyPaymentsApiRefund: _onLegacyPaymentsApiRefund, + onLegacyPaymentsApiVoid: _onLegacyPaymentsApiVoid, + ...boxProps + } = restProps as any; + return ( - + {intl.formatMessage({ - defaultMessage: "Order summary", - id: "SB//YQ", + defaultMessage: "Summary", + id: "RrCui3", })} @@ -85,16 +119,45 @@ export const OrderSummary = (props: Props) => { - + {isEditable && editableProps ? ( + + ) : ( + + )} ; -export const OrderSummaryListItem = ({ children, amount, showSign, currency, ...props }: Props) => { +export const OrderSummaryListItem = ({ + children, + amount, + showSign, + currency, + title, + amountTitle, + ...props +}: Props): ReactNode => { return ( - + {children} - + {currency} {" "} diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 4cc7cf4f0a1..921fca1da6c 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -1,12 +1,132 @@ -import { OrderDetailsFragment } from "@dashboard/graphql"; -import { Box, PropsWithBox, Text } from "@saleor/macaw-ui-next"; -import { useIntl } from "react-intl"; +import { + DiscountValueTypeEnum, + OrderDetailsFragment, + OrderErrorFragment, +} from "@dashboard/graphql"; +import { OrderDiscountContextConsumerProps } from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; +import { OrderDiscountData } from "@dashboard/products/components/OrderDiscountProviders/types"; +import { getFormErrors } from "@dashboard/utils/errors"; +import getOrderErrorMessage from "@dashboard/utils/errors/order"; +import { Box, Popover, PropsWithBox, sprinkles, Text } from "@saleor/macaw-ui-next"; +import { ReactNode } from "react"; +import { defineMessages, useIntl } from "react-intl"; +import OrderDiscountCommonModal from "../OrderDiscountCommonModal"; +import { ORDER_DISCOUNT } from "../OrderDiscountCommonModal/types"; import { OrderSummaryListAmount } from "./OrderSummaryListAmount"; import { OrderSummaryListItem } from "./OrderSummaryListItem"; import { OrderValueHeader } from "./OrderValueHeader"; -type Props = PropsWithBox<{ +const InlineLink = ({ + children, + onClick, + title, + "data-test-id": dataTestId, +}: { + children: ReactNode; + onClick?: () => void; + title?: string; + "data-test-id"?: string; +}): ReactNode => ( + { + e.preventDefault(); + onClick?.(); + }} + href="#" + title={title} + data-test-id={dataTestId} + style={{ + cursor: "pointer", + textDecoration: "none", + }} + __textDecoration={{ hover: "underline" }} + > + {children} + +); + +const messages = defineMessages({ + discount: { + id: "+8v1ny", + defaultMessage: "Discount", + description: "discount button", + }, + addDiscount: { + id: "Myx1Qp", + defaultMessage: "Add Discount", + description: "add discount button", + }, + setShipping: { + id: "zuWUXf", + defaultMessage: "Set shipping", + description: "set shipping link when no shipping method selected", + }, + shipping: { + id: "glR0om", + defaultMessage: "Shipping", + description: "shipping label", + }, + noShippingCarriers: { + id: "M9LXb5", + defaultMessage: "No applicable shipping carriers", + description: "no shipping carriers title", + }, + noAlternativeShippingMethods: { + id: "gCZYcl", + defaultMessage: "No alternative shipping methods available", + description: "tooltip shown when shipping method is selected but no other options exist", + }, + addShippingAddressFirst: { + id: "BjxQ3u", + defaultMessage: "add shipping address first", + description: "add shipping address first label", + }, + netPrices: { + id: "J/6a1+", + defaultMessage: "Net prices", + description: "tooltip for taxes when showing net prices", + }, + grossPrices: { + id: "TdoAea", + defaultMessage: "Gross prices", + description: "tooltip for taxes when showing gross prices", + }, + subtotalTitle: { + id: "ClUKur", + defaultMessage: "Sum of all line items", + description: "tooltip for subtotal amount", + }, + shippingTitle: { + id: "MHY5da", + defaultMessage: "Shipping cost", + description: "tooltip for shipping amount", + }, + taxesTitle: { + id: "fS2rip", + defaultMessage: "Tax amount", + description: "tooltip for taxes amount", + }, + totalTitle: { + id: "aaj/MI", + defaultMessage: "Order total", + description: "tooltip for total amount", + }, + discountTitle: { + id: "i3Hquc", + defaultMessage: "Discount amount", + description: "tooltip for discount amount", + }, + giftCardTitle: { + id: "ztsvOP", + defaultMessage: "Gift card amount used", + description: "tooltip for gift card amount", + }, +}); + +type BaseProps = { orderSubtotal: OrderDetailsFragment["subtotal"]; shippingMethodName: OrderDetailsFragment["shippingMethodName"]; shippingPrice: OrderDetailsFragment["shippingPrice"]; @@ -15,21 +135,271 @@ type Props = PropsWithBox<{ giftCardsAmount: number | null; usedGiftCards: OrderDetailsFragment["giftCards"] | null; displayGrossPrices: OrderDetailsFragment["displayGrossPrices"]; -}>; - -export const OrderValue = ({ - orderSubtotal, - shippingMethodName, - shippingPrice, - orderTotal, - discounts, - giftCardsAmount, - usedGiftCards, - displayGrossPrices, - ...props -}: Props) => { +}; + +type EditableProps = { + isEditable: true; + orderDiscount?: OrderDiscountData; + addOrderDiscount: OrderDiscountContextConsumerProps["addOrderDiscount"]; + removeOrderDiscount: OrderDiscountContextConsumerProps["removeOrderDiscount"]; + openDialog: OrderDiscountContextConsumerProps["openDialog"]; + closeDialog: OrderDiscountContextConsumerProps["closeDialog"]; + isDialogOpen: OrderDiscountContextConsumerProps["isDialogOpen"]; + orderDiscountAddStatus: OrderDiscountContextConsumerProps["orderDiscountAddStatus"]; + orderDiscountRemoveStatus: OrderDiscountContextConsumerProps["orderDiscountRemoveStatus"]; + undiscountedPrice: OrderDiscountContextConsumerProps["undiscountedPrice"]; + onShippingMethodEdit: () => void; + shippingMethods: OrderDetailsFragment["shippingMethods"]; + shippingMethod: OrderDetailsFragment["shippingMethod"]; + shippingAddress: OrderDetailsFragment["shippingAddress"]; + isShippingRequired: OrderDetailsFragment["isShippingRequired"]; + errors?: OrderErrorFragment[]; +}; + +type ReadOnlyProps = { + isEditable?: false; +}; + +type Props = PropsWithBox; + +const getOrderDiscountLabel = ( + orderDiscount: OrderDiscountData | undefined, + _currency: string, +): { value: string; percentage?: string } => { + if (!orderDiscount) { + return { value: "---" }; + } + + const { value: discountValue, calculationMode, amount: discountAmount } = orderDiscount; + + if (calculationMode === DiscountValueTypeEnum.PERCENTAGE) { + return { + value: discountAmount.amount.toFixed(2), + percentage: `${discountValue}%`, + }; + } + + return { value: discountValue.toFixed(2) }; +}; + +export const OrderValue = (props: Props) => { + const { + orderSubtotal, + shippingMethodName, + shippingPrice, + orderTotal, + discounts, + giftCardsAmount, + usedGiftCards, + displayGrossPrices, + isEditable = false, + ...restProps + } = props; const intl = useIntl(); + const editableProps = isEditable ? (props as BaseProps & EditableProps) : null; + + const hasChosenShippingMethod = + editableProps?.shippingMethod !== null && + editableProps?.shippingMethod !== undefined && + shippingMethodName !== null; + const hasShippingMethods = + !!editableProps?.shippingMethods?.length || editableProps?.isShippingRequired; + + const formErrors = editableProps + ? getFormErrors(["shipping"], editableProps.errors ?? []) + : { shipping: undefined }; + + const renderShippingRow = () => { + const shippingAmountTitle = intl.formatMessage(messages.shippingTitle); + + if (!isEditable) { + return ( + + {intl.formatMessage(messages.shipping)}{" "} + + {shippingMethodName} + + + ); + } + + if (hasChosenShippingMethod) { + if (!hasShippingMethods) { + return ( + + {intl.formatMessage(messages.shipping)}{" "} + + {shippingMethodName} + + + ); + } + + return ( + + {intl.formatMessage(messages.shipping)}{" "} + + {shippingMethodName} + + + ); + } + + if (!hasShippingMethods) { + return ( + + + {intl.formatMessage(messages.noShippingCarriers)} + + + ); + } + + const canSetShipping = !!editableProps?.shippingAddress; + + return ( + + {canSetShipping ? ( + + {intl.formatMessage(messages.setShipping)} + + ) : ( + + {intl.formatMessage(messages.setShipping)} + + )} + + ); + }; + + const renderDiscountRow = () => { + const discountAmountTitle = intl.formatMessage(messages.discountTitle); + + if (!isEditable) { + return discounts.map(discount => ( + + {intl.formatMessage(messages.discount)}{" "} + + {discount.name} + + + )); + } + + const hasDiscount = !!editableProps?.orderDiscount; + const discountLabel = getOrderDiscountLabel( + editableProps?.orderDiscount, + orderTotal.gross.currency, + ); + const discountReason = editableProps?.orderDiscount?.reason; + + if (!hasDiscount) { + return ( + + { + if (!val) { + editableProps?.closeDialog(); + } + }} + open={editableProps?.isDialogOpen} + > + + + + {intl.formatMessage(messages.addDiscount)} + + + + + + {editableProps && ( + + )} + + + + + ); + } + + const discountDisplayValue = discountLabel.percentage || discountLabel.value; + const discountAmount = parseFloat(discountLabel.value) || 0; + + return ( + 0} + > + {intl.formatMessage(messages.discount)}{" "} + { + if (!val) { + editableProps?.closeDialog(); + } + }} + open={editableProps?.isDialogOpen} + > + + + + {discountDisplayValue} + + + + + + {editableProps && ( + + )} + + + + + ); + }; + + const { isEditable: _, ...boxProps } = restProps as any; + return ( - + {intl.formatMessage({ defaultMessage: "Subtotal", id: "L8seEc", })} - - {intl.formatMessage( - { - defaultMessage: "Shipping {carrierName}", - id: "mpkBZc", - }, - { - carrierName: ( - - ({shippingMethodName}) - - ), - }, - )} - + + {renderShippingRow()} + + {formErrors.shipping && ( + + + {getOrderErrorMessage(formErrors.shipping, intl)} + + + )} + {!displayGrossPrices && ( - + {intl.formatMessage({ defaultMessage: "Taxes ", id: "HTiAMm", @@ -78,48 +451,9 @@ export const OrderValue = ({ )} - {discounts.map(discount => ( - - {intl.formatMessage( - { - defaultMessage: "Discount {discountName}", - id: "vs0xiH", - }, - { - discountName: ( - - ({discount.name}) - - ), - }, - )} - - ))} - - {giftCardsAmount && giftCardsAmount > 0 && usedGiftCards && ( - - {intl.formatMessage( - { - defaultMessage: - "{usedGiftCards, plural, one {Gift card} other {Gift cards} } {giftCardCodesList}", - id: "5kODlC", - }, - { - usedGiftCards: usedGiftCards.length, - giftCardCodesList: ( - - ({usedGiftCards.map(card => card.last4CodeChars).join(", ")}) - - ), - }, - )} - - )} + {renderDiscountRow()} - + + {displayGrossPrices && ( - + {intl.formatMessage({ defaultMessage: "Taxes ", id: "HTiAMm", @@ -156,6 +495,29 @@ export const OrderValue = ({
)} + + {giftCardsAmount && giftCardsAmount > 0 && usedGiftCards && ( + + {intl.formatMessage( + { + defaultMessage: + "{usedGiftCards, plural, one {Gift card} other {Gift cards} } {giftCardCodesList}", + id: "5kODlC", + }, + { + usedGiftCards: usedGiftCards.length, + giftCardCodesList: ( + + ({usedGiftCards.map(card => card.last4CodeChars).join(", ")}) + + ), + }, + )} + + )}
); From 4de0c0756a2ed9bc374d6b74a2f45ec3c8ad71a0 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 16:59:08 +0100 Subject: [PATCH 11/86] Eslint --- src/orders/components/OrderSummary/OrderValue.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 921fca1da6c..c8c8b2fb934 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -182,7 +182,7 @@ const getOrderDiscountLabel = ( return { value: discountValue.toFixed(2) }; }; -export const OrderValue = (props: Props) => { +export const OrderValue = (props: Props): ReactNode => { const { orderSubtotal, shippingMethodName, @@ -210,7 +210,7 @@ export const OrderValue = (props: Props) => { ? getFormErrors(["shipping"], editableProps.errors ?? []) : { shipping: undefined }; - const renderShippingRow = () => { + const renderShippingRow = (): ReactNode => { const shippingAmountTitle = intl.formatMessage(messages.shippingTitle); if (!isEditable) { @@ -287,7 +287,7 @@ export const OrderValue = (props: Props) => { ); }; - const renderDiscountRow = () => { + const renderDiscountRow = (): ReactNode => { const discountAmountTitle = intl.formatMessage(messages.discountTitle); if (!isEditable) { From 8e09b0681bc63094e7c73a1f2f94469476f4a74c Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 18:01:43 +0100 Subject: [PATCH 12/86] More types --- src/orders/components/OrderSummary/OrderValue.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index c8c8b2fb934..604b2a6ac78 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -12,7 +12,14 @@ import { ReactNode } from "react"; import { defineMessages, useIntl } from "react-intl"; import OrderDiscountCommonModal from "../OrderDiscountCommonModal"; -import { ORDER_DISCOUNT } from "../OrderDiscountCommonModal/types"; +import { ORDER_DISCOUNT, OrderDiscountCommonInput } from "../OrderDiscountCommonModal/types"; + +const emptyDiscount: OrderDiscountCommonInput = { + value: 0, + reason: "", + calculationMode: DiscountValueTypeEnum.PERCENTAGE, +}; + import { OrderSummaryListAmount } from "./OrderSummaryListAmount"; import { OrderSummaryListItem } from "./OrderSummaryListItem"; import { OrderValueHeader } from "./OrderValueHeader"; @@ -336,7 +343,7 @@ export const OrderValue = (props: Props): ReactNode => { {editableProps && ( { {editableProps && ( Date: Mon, 8 Dec 2025 18:03:16 +0100 Subject: [PATCH 13/86] Extract messages --- locale/defaultMessages.json | 85 ++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 44 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 3946c6861c7..8d138947e6f 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1071,10 +1071,6 @@ "context": "account information, header", "string": "Account Information" }, - "4xZFP4": { - "context": "tooltip for shipping amount", - "string": "Shipping cost" - }, "4y3Smi": { "string": "Removing permissions may cause extension to break." }, @@ -1569,10 +1565,6 @@ "context": "checkbox label", "string": "Send fulfillment email to customer" }, - "8EDRSz": { - "context": "tooltip shown when shipping method is selected but no other options exist", - "string": "No alternative shipping methods available" - }, "8EGagh": { "context": "search box label", "string": "Filter Countries" @@ -2283,6 +2275,10 @@ "context": "channel publication status", "string": "Not published" }, + "ClUKur": { + "context": "tooltip for subtotal amount", + "string": "Sum of all line items" + }, "CmxKIg": { "context": "window title", "string": "Grant refund" @@ -2885,10 +2881,6 @@ "context": "dialog header", "string": "Select destination channel:" }, - "GSC7Rw": { - "context": "tooltip for taxes amount", - "string": "Tax amount" - }, "GTCg9O": { "string": "You must add at least one voucher code" }, @@ -2954,10 +2946,6 @@ "context": "add metadata field,button", "string": "Add Field" }, - "GiJ1QZ": { - "context": "tooltip for total amount", - "string": "Order total" - }, "GiJm1v": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to unassign this category?} other{Are you sure you want to unassign {displayQuantity} categories?}}" @@ -3293,10 +3281,6 @@ "context": "header", "string": "Edit Media" }, - "IirEpN": { - "context": "tooltip for taxes when showing gross prices", - "string": "Gross prices" - }, "Ila7WO": { "context": "change warehouse dialog description", "string": "Choose warehouse from which you want to fulfill {productName}" @@ -3334,7 +3318,7 @@ "context": "bulk disable label", "string": "Deactivate" }, - "IzrVyw": { + "J/6a1+": { "context": "tooltip for taxes when showing net prices", "string": "Net prices" }, @@ -3519,10 +3503,6 @@ "context": "refund button", "string": "Refund" }, - "K/a8rS": { - "context": "tooltip for subtotal amount", - "string": "Sum of all line items" - }, "K/gnGg": { "string": "If you want to disable this User please uncheck the box below." }, @@ -3806,6 +3786,10 @@ "context": "deactivate", "string": "Deactivate" }, + "MHY5da": { + "context": "tooltip for shipping amount", + "string": "Shipping cost" + }, "MIC9W7": { "context": "WarehouseSettings pickup title", "string": "Pickup" @@ -4322,10 +4306,6 @@ "context": "back button", "string": "Back" }, - "PRlD0A": { - "context": "shipping label", - "string": "Shipping" - }, "PTW56s": { "context": "alert", "string": "Channel limit reached" @@ -4782,9 +4762,6 @@ "S8kqP9": { "string": "Conditions" }, - "SB//YQ": { - "string": "Summary" - }, "SBb6Ej": { "context": "select a warehouse to fulfill product from", "string": "Select warehouse..." @@ -5022,6 +4999,10 @@ "TdTXXf": { "string": "Learn more" }, + "TdoAea": { + "context": "tooltip for taxes when showing gross prices", + "string": "Gross prices" + }, "Tenl9A": { "context": "grant refund, refund card toggle", "string": "Refund shipment: {currency} {amount}" @@ -6038,10 +6019,6 @@ "context": "note input subtitle", "string": "Why was this gift card issued. This note will not be shown to the customer. Note will be stored in gift card history" }, - "Zvjkx8": { - "context": "tooltip for discount amount", - "string": "Discount amount" - }, "Zvo5iu": { "string": "API reference" }, @@ -6174,6 +6151,10 @@ "context": "price rates info", "string": "This rate will apply to all orders" }, + "aaj/MI": { + "context": "tooltip for total amount", + "string": "Order total" + }, "aasX8r": { "context": "label", "string": "Link type" @@ -6861,6 +6842,10 @@ "context": "input label", "string": "Search Attributes" }, + "fS2rip": { + "context": "tooltip for taxes amount", + "string": "Tax amount" + }, "fU+a9k": { "context": "date attribute type", "string": "Date" @@ -6999,6 +6984,10 @@ "context": "gift card history message", "string": "Gift card was deactivated by {deactivatedBy}" }, + "gCZYcl": { + "context": "tooltip shown when shipping method is selected but no other options exist", + "string": "No alternative shipping methods available" + }, "gE6aiQ": { "context": "PageTypeDeleteWarningDialog single no assigned items description", "string": "Are you sure you want to delete {typeName}? If you remove it you won’t be able to assign it to created models." @@ -7069,6 +7058,10 @@ "context": "button", "string": "Create product type" }, + "glR0om": { + "context": "shipping label", + "string": "Shipping" + }, "glT6fm": { "string": "Voucher is limited to these countries" }, @@ -7268,6 +7261,10 @@ "context": "section header returned", "string": "Fulfillment waiting for approval" }, + "i3Hquc": { + "context": "tooltip for discount amount", + "string": "Discount amount" + }, "i3Mvj8": { "context": "product attribute error", "string": "This variant already exists" @@ -8831,10 +8828,6 @@ "context": "limit voucher", "string": "Limit of Uses" }, - "s6YKKO": { - "context": "set shipping link when no shipping method selected", - "string": "Set shipping" - }, "s6lW8R": { "context": "option label", "string": "Change address" @@ -9470,10 +9463,6 @@ "vuKrlW": { "string": "Stock" }, - "vuNPLt": { - "context": "tooltip for gift card amount", - "string": "Gift card amount used" - }, "vwA9Fq": { "context": "notification", "string": "Selected models were deleted." @@ -10086,10 +10075,18 @@ "ztQgD8": { "string": "No attributes found" }, + "ztsvOP": { + "context": "tooltip for gift card amount", + "string": "Gift card amount used" + }, "ztvvcm": { "context": "swatch attribute type", "string": "Swatch type" }, + "zuWUXf": { + "context": "set shipping link when no shipping method selected", + "string": "Set shipping" + }, "zv9OGI": { "string": "No models found" }, From a7905f84682cef2df636e91189c39d26a566e865 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 18:06:12 +0100 Subject: [PATCH 14/86] Add changeset --- .changeset/few-bushes-ask.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/few-bushes-ask.md diff --git a/.changeset/few-bushes-ask.md b/.changeset/few-bushes-ask.md new file mode 100644 index 00000000000..2619b8d8bf7 --- /dev/null +++ b/.changeset/few-bushes-ask.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Unify Draft and Unconfirmed order info with the rest of the statuses From f90f52b52b23d25be1151c2c0e90dedc59009908 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 8 Dec 2025 18:10:14 +0100 Subject: [PATCH 15/86] Fix imports placement --- src/orders/components/OrderSummary/OrderValue.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 604b2a6ac78..1b8db507b50 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -13,6 +13,9 @@ import { defineMessages, useIntl } from "react-intl"; import OrderDiscountCommonModal from "../OrderDiscountCommonModal"; import { ORDER_DISCOUNT, OrderDiscountCommonInput } from "../OrderDiscountCommonModal/types"; +import { OrderSummaryListAmount } from "./OrderSummaryListAmount"; +import { OrderSummaryListItem } from "./OrderSummaryListItem"; +import { OrderValueHeader } from "./OrderValueHeader"; const emptyDiscount: OrderDiscountCommonInput = { value: 0, @@ -20,10 +23,6 @@ const emptyDiscount: OrderDiscountCommonInput = { calculationMode: DiscountValueTypeEnum.PERCENTAGE, }; -import { OrderSummaryListAmount } from "./OrderSummaryListAmount"; -import { OrderSummaryListItem } from "./OrderSummaryListItem"; -import { OrderValueHeader } from "./OrderValueHeader"; - const InlineLink = ({ children, onClick, From c0d91044c54666645224697921caa1d128cba5ad Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 15:19:02 +0100 Subject: [PATCH 16/86] Clean up the old unused Draft summary component --- .../OrderDetailsPage/OrderDetailsPage.tsx | 2 - .../OrderDraftDetails/OrderDraftDetails.tsx | 25 -- .../OrderDraftDetailsSummary.tsx | 243 ------------------ .../OrderDraftDetailsSummary/index.ts | 2 - .../OrderDraftDetailsSummary/messages.ts | 49 ---- .../OrderDraftPage/OrderDraftPage.tsx | 2 - 6 files changed, 323 deletions(-) delete mode 100644 src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx delete mode 100644 src/orders/components/OrderDraftDetailsSummary/index.ts delete mode 100644 src/orders/components/OrderDraftDetailsSummary/messages.ts diff --git a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx index 8025b84c89b..632e7e4e93b 100644 --- a/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx +++ b/src/orders/components/OrderDetailsPage/OrderDetailsPage.tsx @@ -252,8 +252,6 @@ const OrderDetailsPage = (props: OrderDetailsPageProps) => { onOrderLineAdd={onOrderLineAdd} onOrderLineChange={onOrderLineChange} onOrderLineRemove={onOrderLineRemove} - onShippingMethodEdit={onShippingMethodEdit} - hideSummary /> diff --git a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx index 8fe1f63cbbe..c35c547214f 100644 --- a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx +++ b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx @@ -6,16 +6,10 @@ import { OrderErrorFragment, OrderLineInput, } from "@dashboard/graphql"; -import { - OrderDiscountContext, - OrderDiscountContextConsumerProps, -} from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; import { Button } from "@saleor/macaw-ui-next"; import { FormattedMessage, useIntl } from "react-intl"; -import { maybe } from "../../../misc"; import OrderDraftDetailsProducts from "../OrderDraftDetailsProducts/OrderDraftDetailsProducts"; -import OrderDraftDetailsSummary from "../OrderDraftDetailsSummary"; interface OrderDraftDetailsProps { order: OrderDetailsFragment; @@ -25,10 +19,7 @@ interface OrderDraftDetailsProps { onOrderLineAdd: () => void; onOrderLineChange: (id: string, data: OrderLineInput) => void; onOrderLineRemove: (id: string) => void; - onShippingMethodEdit: () => void; onOrderLineShowMetadata: (id: string) => void; - /** Hide the summary section (when using OrderSummary component instead) */ - hideSummary?: boolean; } const OrderDraftDetails = ({ @@ -39,9 +30,7 @@ const OrderDraftDetails = ({ onOrderLineAdd, onOrderLineChange, onOrderLineRemove, - onShippingMethodEdit, onOrderLineShowMetadata, - hideSummary = false, }: OrderDraftDetailsProps) => { const intl = useIntl(); const isChannelActive = order?.channel.isActive; @@ -73,20 +62,6 @@ const OrderDraftDetails = ({ onOrderLineRemove={onOrderLineRemove} onOrderLineShowMetadata={onOrderLineShowMetadata} /> - {!hideSummary && maybe(() => order.lines.length) !== 0 && ( - - - {(orderDiscountProps: OrderDiscountContextConsumerProps) => ( - - )} - - - )} ); }; diff --git a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx b/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx deleted file mode 100644 index 9614a8443db..00000000000 --- a/src/orders/components/OrderDraftDetailsSummary/OrderDraftDetailsSummary.tsx +++ /dev/null @@ -1,243 +0,0 @@ -// @ts-strict-ignore -import { ButtonLink } from "@dashboard/components/ButtonLink"; -import HorizontalSpacer from "@dashboard/components/HorizontalSpacer"; -import Money from "@dashboard/components/Money"; -import { - DiscountValueTypeEnum, - OrderDetailsFragment, - OrderErrorFragment, -} from "@dashboard/graphql"; -import { OrderDiscountContextConsumerProps } from "@dashboard/products/components/OrderDiscountProviders/OrderDiscountProvider"; -import { OrderDiscountData } from "@dashboard/products/components/OrderDiscountProviders/types"; -import { getFormErrors } from "@dashboard/utils/errors"; -import getOrderErrorMessage from "@dashboard/utils/errors/order"; -import { makeStyles } from "@saleor/macaw-ui"; -import { Box, Popover, sprinkles, Text } from "@saleor/macaw-ui-next"; -import { useIntl } from "react-intl"; - -import OrderDiscountCommonModal from "../OrderDiscountCommonModal"; -import { ORDER_DISCOUNT } from "../OrderDiscountCommonModal/types"; -import { messages } from "./messages"; - -const useStyles = makeStyles( - theme => ({ - root: { - ...theme.typography.body1, - lineHeight: 1.9, - width: "100%", - }, - textRight: { - textAlign: "right", - }, - textError: { - color: theme.palette.error.main, - marginLeft: theme.spacing(1.5), - display: "inline", - }, - subtitle: { - color: theme.palette.grey[500], - paddingRight: theme.spacing(1), - }, - relativeRow: { - position: "relative", - }, - percentDiscountLabelContainer: { - display: "flex", - flexDirection: "row", - alignItems: "baseline", - justifyContent: "flex-end", - }, - shippingMethodContainer: { - display: "flex", - flexDirection: "row", - alignItems: "baseline", - }, - }), - { name: "OrderDraftDetailsSummary" }, -); -const PRICE_PLACEHOLDER = "---"; - -interface OrderDraftDetailsSummaryProps extends OrderDiscountContextConsumerProps { - disabled?: boolean; - order: OrderDetailsFragment; - errors: OrderErrorFragment[]; - onShippingMethodEdit: () => void; -} - -const OrderDraftDetailsSummary = (props: OrderDraftDetailsSummaryProps) => { - const { - order, - errors, - onShippingMethodEdit, - orderDiscount, - addOrderDiscount, - removeOrderDiscount, - openDialog, - closeDialog, - isDialogOpen, - orderDiscountAddStatus, - orderDiscountRemoveStatus, - undiscountedPrice, - } = props; - const intl = useIntl(); - const classes = useStyles(props); - - if (!order) { - return null; - } - - const { - subtotal, - total, - shippingMethod, - shippingMethodName, - shippingMethods, - shippingPrice, - shippingAddress, - isShippingRequired, - } = order; - const formErrors = getFormErrors(["shipping"], errors); - const hasChosenShippingMethod = shippingMethod !== null && shippingMethodName !== null; - const hasShippingMethods = !!shippingMethods?.length || isShippingRequired; - const discountTitle = orderDiscount ? messages.discount : messages.addDiscount; - const getOrderDiscountLabel = (orderDiscountData: OrderDiscountData) => { - if (!orderDiscountData) { - return PRICE_PLACEHOLDER; - } - - const { value: discountValue, calculationMode, amount: discountAmount } = orderDiscountData; - const currency = total.gross.currency; - - if (calculationMode === DiscountValueTypeEnum.PERCENTAGE) { - return ( -
- {`(${discountValue}%)`} - -
- ); - } - - return ; - }; - const getShippingMethodComponent = () => { - if (hasChosenShippingMethod) { - // Shipping method is selected but no alternatives available - show as plain text with tooltip - if (!hasShippingMethods) { - return ( - - {shippingMethodName} - - ); - } - - return {`${shippingMethodName}`}; - } - - const shippingCarrierBase = intl.formatMessage(messages.addShippingCarrier); - - if (shippingAddress) { - return ( - - {shippingCarrierBase} - - ); - } - - const addShippingAddressInfo = intl.formatMessage(messages.addShippingAddressInfo); - - return ( -
- - {shippingCarrierBase} - - - {`(${addShippingAddressInfo})`} -
- ); - }; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - -
- { - if (!val) { - closeDialog(); - } - }} - open={isDialogOpen} - > - - - - {intl.formatMessage(discountTitle)} - - - - - - - - - - {getOrderDiscountLabel(orderDiscount)}
{intl.formatMessage(messages.subtotal)} - -
- {(hasShippingMethods || hasChosenShippingMethod) && getShippingMethodComponent()} - - {!hasShippingMethods && - !hasChosenShippingMethod && - intl.formatMessage(messages.noShippingCarriers)} - - {formErrors.shipping && ( - - {getOrderErrorMessage(formErrors.shipping, intl)} - - )} - - {hasChosenShippingMethod ? : PRICE_PLACEHOLDER} -
{intl.formatMessage(messages.taxes)} - -
{intl.formatMessage(messages.total)} - -
- ); -}; - -export default OrderDraftDetailsSummary; diff --git a/src/orders/components/OrderDraftDetailsSummary/index.ts b/src/orders/components/OrderDraftDetailsSummary/index.ts deleted file mode 100644 index 78bbd58f09d..00000000000 --- a/src/orders/components/OrderDraftDetailsSummary/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from "./OrderDraftDetailsSummary"; -export * from "./OrderDraftDetailsSummary"; diff --git a/src/orders/components/OrderDraftDetailsSummary/messages.ts b/src/orders/components/OrderDraftDetailsSummary/messages.ts deleted file mode 100644 index b195c069684..00000000000 --- a/src/orders/components/OrderDraftDetailsSummary/messages.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { defineMessages } from "react-intl"; - -export const messages = defineMessages({ - addShippingAddressInfo: { - id: "BjxQ3u", - defaultMessage: "add shipping address first", - description: "add shipping address first label", - }, - subtotal: { - id: "xUvWaP", - defaultMessage: "Subtotal", - description: "subtotal price", - }, - addDiscount: { - id: "Myx1Qp", - defaultMessage: "Add Discount", - description: "add discount button", - }, - discount: { - id: "+8v1ny", - defaultMessage: "Discount", - description: "discount button", - }, - addShippingCarrier: { - id: "Jb1/3V", - defaultMessage: "Add shipping carrier", - description: "add shipping carrier button", - }, - noShippingCarriers: { - id: "M9LXb5", - defaultMessage: "No applicable shipping carriers", - description: "no shipping carriers title", - }, - noAlternativeShippingMethods: { - id: "gCZYcl", - defaultMessage: "No alternative shipping methods available", - description: "tooltip shown when shipping method is selected but no other options exist", - }, - total: { - id: "S/yAtJ", - defaultMessage: "Total", - description: "total price", - }, - taxes: { - id: "mQtoRO", - defaultMessage: "Taxes (VAT included)", - description: "taxes title", - }, -}); diff --git a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx index 256199f318e..36e2f0004ab 100644 --- a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx +++ b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx @@ -151,9 +151,7 @@ const OrderDraftPage = (props: OrderDraftPageProps) => { onOrderLineAdd={onOrderLineAdd} onOrderLineChange={onOrderLineChange} onOrderLineRemove={onOrderLineRemove} - onShippingMethodEdit={onShippingMethodEdit} onOrderLineShowMetadata={onOrderLineShowMetadata} - hideSummary /> {order && orderDiscountContext && ( <> From d7a0151a9ea14f4373938f17176173c3570047bb Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 15:36:22 +0100 Subject: [PATCH 17/86] Unify the Draft header style and reuse OrderCardTitle component --- locale/defaultMessages.json | 24 ++------ .../OrderCardTitle/OrderCardTitle.tsx | 2 +- .../components/OrderCardTitle/messages.ts | 5 ++ src/orders/components/OrderCardTitle/utils.ts | 1 + .../OrderDraftDetails/OrderDraftDetails.tsx | 57 ++++++++++--------- 5 files changed, 41 insertions(+), 48 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 300cf0d9e29..a2e0962ba48 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -546,10 +546,6 @@ "16sza6": { "string": "Refund grants reserve a money which later can be sent to customers via original payment methods or a manual transaction." }, - "18wvf7": { - "context": "section header", - "string": "Order Details" - }, "19/lwV": { "string": "Determine attributes used to create product types" }, @@ -1348,6 +1344,10 @@ "6Y1YDn": { "string": "Updated extension permissions" }, + "6YKFVP": { + "context": "draft order lines, section header", + "string": "Order lines" + }, "6ZubLQ": { "string": "Manage available refunds reasons" }, @@ -3430,10 +3430,6 @@ "context": "button", "string": "Edit" }, - "Jb1/3V": { - "context": "add shipping carrier button", - "string": "Add shipping carrier" - }, "JbUg2b": { "context": "activate app", "string": "Are you sure you want to activate {name}? Activating will start gathering events." @@ -4727,10 +4723,6 @@ "RzsKm8": { "string": "Shipping methods" }, - "S/yAtJ": { - "context": "total price", - "string": "Total" - }, "S1u2pa": { "context": "app settings error", "string": "Failed to fetch extension settings" @@ -7963,10 +7955,6 @@ "mMOskm": { "string": "Open product detail" }, - "mQtoRO": { - "context": "taxes title", - "string": "Taxes (VAT included)" - }, "mSCZd4": { "context": "Webhook details asynchronous events", "string": "Asynchronous" @@ -9695,10 +9683,6 @@ "xTyg+p": { "string": "No options to select" }, - "xUvWaP": { - "context": "subtotal price", - "string": "Subtotal" - }, "xVn5B0": { "context": "added new attribute value", "string": "Added new value" diff --git a/src/orders/components/OrderCardTitle/OrderCardTitle.tsx b/src/orders/components/OrderCardTitle/OrderCardTitle.tsx index 480a2242148..e140daca0f7 100644 --- a/src/orders/components/OrderCardTitle/OrderCardTitle.tsx +++ b/src/orders/components/OrderCardTitle/OrderCardTitle.tsx @@ -9,7 +9,7 @@ import { TrackingNumberDisplay } from "./TrackingNumberDisplay"; import { getOrderTitleMessage } from "./utils"; import { WarehouseInfo } from "./WarehouseInfo"; -export type CardTitleStatus = FulfillmentStatus | "unfulfilled"; +export type CardTitleStatus = FulfillmentStatus | "unfulfilled" | "draft"; type BaseOrderCardTitleProps = { status?: CardTitleStatus; diff --git a/src/orders/components/OrderCardTitle/messages.ts b/src/orders/components/OrderCardTitle/messages.ts index da64b4eccdd..0616113c4f7 100644 --- a/src/orders/components/OrderCardTitle/messages.ts +++ b/src/orders/components/OrderCardTitle/messages.ts @@ -41,6 +41,11 @@ export const orderTitleMessages = defineMessages({ id: "vcMnX2", description: "unfulfilled fulfillment, section header", }, + draft: { + defaultMessage: "Order lines", + id: "wL850U", + description: "draft order lines, section header", + }, fulfilledFromWarehouse: { id: "W2glWk", defaultMessage: "From {warehouseName}", diff --git a/src/orders/components/OrderCardTitle/utils.ts b/src/orders/components/OrderCardTitle/utils.ts index 7973006001a..2f5b1c83c15 100644 --- a/src/orders/components/OrderCardTitle/utils.ts +++ b/src/orders/components/OrderCardTitle/utils.ts @@ -13,6 +13,7 @@ const STATUS_MESSAGE_MAP: Record = { [FulfillmentStatus.RETURNED]: orderTitleMessages.returned, [FulfillmentStatus.WAITING_FOR_APPROVAL]: orderTitleMessages.waitingForApproval, unfulfilled: orderTitleMessages.unfulfilled, + draft: orderTitleMessages.draft, }; export const getOrderTitleMessage = (status?: CardTitleStatus): MessageDescriptor => diff --git a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx index c35c547214f..cefc9886a03 100644 --- a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx +++ b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx @@ -6,9 +6,10 @@ import { OrderErrorFragment, OrderLineInput, } from "@dashboard/graphql"; -import { Button } from "@saleor/macaw-ui-next"; -import { FormattedMessage, useIntl } from "react-intl"; +import { Box, Button } from "@saleor/macaw-ui-next"; +import { FormattedMessage } from "react-intl"; +import { OrderCardTitle } from "../OrderCardTitle/OrderCardTitle"; import OrderDraftDetailsProducts from "../OrderDraftDetailsProducts/OrderDraftDetailsProducts"; interface OrderDraftDetailsProps { @@ -32,36 +33,38 @@ const OrderDraftDetails = ({ onOrderLineRemove, onOrderLineShowMetadata, }: OrderDraftDetailsProps) => { - const intl = useIntl(); const isChannelActive = order?.channel.isActive; const areProductsInChannel = !!channelUsabilityData?.products.totalCount; return ( - - - - {intl.formatMessage({ - id: "18wvf7", - defaultMessage: "Order Details", - description: "section header", - })} - - - {isChannelActive && areProductsInChannel && ( - - )} - - - + + + + ) + } /> + + + ); }; From 4aae8de13486e718cb899271fa57ecbbe56b3242 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 15:54:05 +0100 Subject: [PATCH 18/86] Improve the Add products button showing disabled state When no products are available in the channel or channel is inactive. --- locale/defaultMessages.json | 12 +- .../OrderDraftDetails.test.tsx | 117 ++++++++++++++++++ .../OrderDraftDetails/OrderDraftDetails.tsx | 47 +++++-- 3 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 src/orders/components/OrderDraftDetails/OrderDraftDetails.test.tsx diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index a2e0962ba48..94fb31a7e19 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1344,10 +1344,6 @@ "6Y1YDn": { "string": "Updated extension permissions" }, - "6YKFVP": { - "context": "draft order lines, section header", - "string": "Order lines" - }, "6ZubLQ": { "string": "Manage available refunds reasons" }, @@ -3396,6 +3392,10 @@ "context": "expiry date checkbox label", "string": "Gift card expires" }, + "JLH97N": { + "context": "add products button tooltip", + "string": "Add products from {channelName}" + }, "JMBsrr": { "context": "table header available stock label", "string": "Available" @@ -9527,6 +9527,10 @@ "wL7VAE": { "string": "Actions" }, + "wL850U": { + "context": "draft order lines, section header", + "string": "Order lines" + }, "wLB8B3": { "context": "column header", "string": "Visibility" diff --git a/src/orders/components/OrderDraftDetails/OrderDraftDetails.test.tsx b/src/orders/components/OrderDraftDetails/OrderDraftDetails.test.tsx new file mode 100644 index 00000000000..ad5049cc59c --- /dev/null +++ b/src/orders/components/OrderDraftDetails/OrderDraftDetails.test.tsx @@ -0,0 +1,117 @@ +import { channelsList } from "@dashboard/channels/fixtures"; +import { channelUsabilityData, order } from "@dashboard/orders/fixtures"; +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; + +import OrderDraftDetails from "./OrderDraftDetails"; + +// Mock the child component to avoid needing complex context providers +jest.mock("../OrderDraftDetailsProducts/OrderDraftDetailsProducts", () => ({ + __esModule: true, + default: () =>
, +})); + +describe("OrderDraftDetails", () => { + const defaultProps = { + order: order("--placeholder--"), + channelUsabilityData, + errors: [], + loading: false, + onOrderLineAdd: jest.fn(), + onOrderLineChange: jest.fn(), + onOrderLineRemove: jest.fn(), + onOrderLineShowMetadata: jest.fn(), + }; + + it("renders Add products button enabled with channel tooltip when channel is active and has products", () => { + // Arrange + render( + + + , + ); + + // Act + const button = screen.getByTestId("add-products-button"); + + // Assert + expect(button).toBeInTheDocument(); + expect(button).not.toBeDisabled(); + // Note: intl formatMessage in test env may not interpolate, so we check for the pattern + expect(button.getAttribute("title")).toMatch(/Add products from/); + }); + + it("renders Add products button disabled with tooltip when channel is inactive", () => { + // Arrange + const inactiveChannelOrder = { + ...order("--placeholder--"), + channel: { ...channelsList[0], isActive: false }, + }; + + render( + + + , + ); + + // Act + const button = screen.getByTestId("add-products-button"); + + // Assert + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("title", "Orders cannot be placed in an inactive channel."); + }); + + it("renders Add products button disabled with tooltip when no products in channel", () => { + // Arrange + const noProductsChannelData = { + ...channelUsabilityData, + products: { totalCount: 0, __typename: "ProductCountableConnection" as const }, + }; + + render( + + + , + ); + + // Act + const button = screen.getByTestId("add-products-button"); + + // Assert + expect(button).toBeInTheDocument(); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("title", "There are no available products in this channel."); + }); + + it("shows inactive channel tooltip when both channel is inactive and no products", () => { + // Arrange + const inactiveChannelOrder = { + ...order("--placeholder--"), + channel: { ...channelsList[0], isActive: false }, + }; + const noProductsChannelData = { + ...channelUsabilityData, + products: { totalCount: 0, __typename: "ProductCountableConnection" as const }, + }; + + render( + + + , + ); + + // Act + const button = screen.getByTestId("add-products-button"); + + // Assert + expect(button).toBeDisabled(); + // Inactive channel takes precedence + expect(button).toHaveAttribute("title", "Orders cannot be placed in an inactive channel."); + }); +}); diff --git a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx index cefc9886a03..7f82365f9f5 100644 --- a/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx +++ b/src/orders/components/OrderDraftDetails/OrderDraftDetails.tsx @@ -7,10 +7,11 @@ import { OrderLineInput, } from "@dashboard/graphql"; import { Box, Button } from "@saleor/macaw-ui-next"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, useIntl } from "react-intl"; import { OrderCardTitle } from "../OrderCardTitle/OrderCardTitle"; import OrderDraftDetailsProducts from "../OrderDraftDetailsProducts/OrderDraftDetailsProducts"; +import { alertMessages } from "../OrderDraftPage/messages"; interface OrderDraftDetailsProps { order: OrderDetailsFragment; @@ -33,26 +34,46 @@ const OrderDraftDetails = ({ onOrderLineRemove, onOrderLineShowMetadata, }: OrderDraftDetailsProps) => { + const intl = useIntl(); const isChannelActive = order?.channel.isActive; const areProductsInChannel = !!channelUsabilityData?.products.totalCount; + const canAddProducts = isChannelActive && areProductsInChannel; + + const getTooltip = () => { + if (!isChannelActive) { + return intl.formatMessage(alertMessages.inactiveChannel); + } + + if (!areProductsInChannel) { + return intl.formatMessage(alertMessages.noProductsInChannel); + } + + return intl.formatMessage( + { + id: "empNV9", + defaultMessage: "Add products from {channelName}", + description: "add products button tooltip", + }, + { channelName: order?.channel.name }, + ); + }; return ( - - - ) + + + } /> From c77648ca11a36dd7411eff8f5251286e5d3cfa20 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 15:56:41 +0100 Subject: [PATCH 19/86] Extract messages --- locale/defaultMessages.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 94fb31a7e19..e5f122ea4a7 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3392,10 +3392,6 @@ "context": "expiry date checkbox label", "string": "Gift card expires" }, - "JLH97N": { - "context": "add products button tooltip", - "string": "Add products from {channelName}" - }, "JMBsrr": { "context": "table header available stock label", "string": "Available" @@ -6750,6 +6746,10 @@ "context": "all captured amount from transactions in order", "string": "Captured" }, + "empNV9": { + "context": "add products button tooltip", + "string": "Add products from {channelName}" + }, "epB7b7": { "context": "fulfillment status replaced", "string": "Replaced" From b1c6b522ce570718523c983841048cdb796572e4 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 16:30:24 +0100 Subject: [PATCH 20/86] Improve the "Set shipping" logic and messages in Order Summary --- locale/defaultMessages.json | 12 ++-- .../components/OrderSummary/OrderValue.tsx | 62 +++++++++---------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index e5f122ea4a7..c5ebfea2744 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -2106,8 +2106,8 @@ "string": "Address Information" }, "BjxQ3u": { - "context": "add shipping address first label", - "string": "add shipping address first" + "context": "shown when shipping address is required but not set", + "string": "No shipping address" }, "Bkxrhw": { "context": "no webhooks message", @@ -3770,8 +3770,8 @@ "string": "If selected customer won't be able to choose this warehouse as pickup point" }, "M9LXb5": { - "context": "no shipping carriers title", - "string": "No applicable shipping carriers" + "context": "no shipping methods available", + "string": "No applicable shipping methods" }, "MAsLIT": { "context": "custom app token key", @@ -10075,8 +10075,8 @@ "string": "Swatch type" }, "zuWUXf": { - "context": "set shipping link when no shipping method selected", - "string": "Set shipping" + "context": "set shipping method link when no shipping method selected", + "string": "Set shipping method" }, "zv9OGI": { "string": "No models found" diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 7cae2cd126d..e04514c1d9a 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -65,30 +65,30 @@ const messages = defineMessages({ defaultMessage: "Add Discount", description: "add discount button", }, - setShipping: { - id: "zuWUXf", - defaultMessage: "Set shipping", - description: "set shipping link when no shipping method selected", + setShippingMethod: { + id: "Dg4Y2p", + defaultMessage: "Set shipping method", + description: "set shipping method link when no shipping method selected", }, shipping: { id: "glR0om", defaultMessage: "Shipping", description: "shipping label", }, - noShippingCarriers: { - id: "M9LXb5", - defaultMessage: "No applicable shipping carriers", - description: "no shipping carriers title", + noShippingMethods: { + id: "G+MtyG", + defaultMessage: "No applicable shipping methods", + description: "no shipping methods available", }, noAlternativeShippingMethods: { id: "gCZYcl", defaultMessage: "No alternative shipping methods available", description: "tooltip shown when shipping method is selected but no other options exist", }, - addShippingAddressFirst: { - id: "BjxQ3u", - defaultMessage: "add shipping address first", - description: "add shipping address first label", + noShippingAddress: { + id: "Ai4XSg", + defaultMessage: "No shipping address", + description: "shown when shipping address is required but not set", }, netPrices: { id: "J/6a1+", @@ -259,36 +259,36 @@ export const OrderValue = (props: Props): ReactNode => { ); } - if (!hasShippingMethods) { + const hasShippingAddress = !!editableProps?.shippingAddress; + + if (!hasShippingAddress) { return ( - {intl.formatMessage(messages.noShippingCarriers)} + {intl.formatMessage(messages.noShippingAddress)} ); } - const canSetShipping = !!editableProps?.shippingAddress; + if (!hasShippingMethods) { + return ( + + + {intl.formatMessage(messages.noShippingMethods)} + + + ); + } return ( - {canSetShipping ? ( - - {intl.formatMessage(messages.setShipping)} - - ) : ( - - {intl.formatMessage(messages.setShipping)} - - )} + + {intl.formatMessage(messages.setShippingMethod)} + ); }; From f4d1ec1ea9036d7b537ed8037204b7ef7dc511c0 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 16:37:33 +0100 Subject: [PATCH 21/86] Add tests to Order summary --- .../OrderSummary/OrderValue.test.tsx | 583 ++++++++++++++++++ 1 file changed, 583 insertions(+) create mode 100644 src/orders/components/OrderSummary/OrderValue.test.tsx diff --git a/src/orders/components/OrderSummary/OrderValue.test.tsx b/src/orders/components/OrderSummary/OrderValue.test.tsx new file mode 100644 index 00000000000..abaefc19b79 --- /dev/null +++ b/src/orders/components/OrderSummary/OrderValue.test.tsx @@ -0,0 +1,583 @@ +import { DiscountValueTypeEnum, OrderDetailsFragment } from "@dashboard/graphql"; +import { prepareMoney } from "@dashboard/orders/fixtures"; +import { OrderDiscountData } from "@dashboard/products/components/OrderDiscountProviders/types"; +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { OrderValue } from "./OrderValue"; + +type BaseOrderValueProps = { + orderSubtotal: OrderDetailsFragment["subtotal"]; + shippingMethodName: OrderDetailsFragment["shippingMethodName"]; + shippingPrice: OrderDetailsFragment["shippingPrice"]; + orderTotal: OrderDetailsFragment["total"]; + discounts: OrderDetailsFragment["discounts"]; + giftCardsAmount: number | null; + usedGiftCards: OrderDetailsFragment["giftCards"] | null; + displayGrossPrices: OrderDetailsFragment["displayGrossPrices"]; +}; + +const baseProps: BaseOrderValueProps = { + orderSubtotal: { + __typename: "TaxedMoney", + gross: { __typename: "Money", amount: 100, currency: "USD" }, + net: { __typename: "Money", amount: 100, currency: "USD" }, + }, + shippingMethodName: null, + shippingPrice: { + __typename: "TaxedMoney", + gross: { __typename: "Money", amount: 10, currency: "USD" }, + }, + orderTotal: { + __typename: "TaxedMoney", + gross: { __typename: "Money", amount: 110, currency: "USD" }, + net: { __typename: "Money", amount: 110, currency: "USD" }, + tax: { __typename: "Money", amount: 0, currency: "USD" }, + }, + discounts: [], + giftCardsAmount: null, + usedGiftCards: null, + displayGrossPrices: true, +}; + +const shippingAddress: OrderDetailsFragment["shippingAddress"] = { + __typename: "Address", + id: "address-1", + city: "New York", + cityArea: "", + companyName: "", + country: { __typename: "CountryDisplay", code: "US", country: "United States" }, + countryArea: "NY", + firstName: "John", + lastName: "Doe", + phone: "+1234567890", + postalCode: "10001", + streetAddress1: "123 Main St", + streetAddress2: "", +}; + +const shippingMethod: OrderDetailsFragment["shippingMethod"] = { + __typename: "ShippingMethod", + id: "shipping-method-1", +}; + +const shippingMethods: OrderDetailsFragment["shippingMethods"] = [ + { + __typename: "ShippingMethod", + id: "shipping-method-1", + name: "Standard Shipping", + price: { __typename: "Money", amount: 10, currency: "USD" }, + active: true, + message: null, + }, + { + __typename: "ShippingMethod", + id: "shipping-method-2", + name: "Express Shipping", + price: { __typename: "Money", amount: 25, currency: "USD" }, + active: true, + message: null, + }, +]; + +const createEditableProps = (overrides: Partial[0]> = {}) => ({ + ...baseProps, + isEditable: true as const, + orderDiscount: undefined, + addOrderDiscount: jest.fn(), + removeOrderDiscount: jest.fn(), + openDialog: jest.fn(), + closeDialog: jest.fn(), + isDialogOpen: false, + orderDiscountAddStatus: "default" as const, + orderDiscountRemoveStatus: "default" as const, + undiscountedPrice: prepareMoney(110), + onShippingMethodEdit: jest.fn(), + shippingMethods: [], + shippingMethod: null, + shippingAddress: null, + isShippingRequired: true, + errors: [], + ...overrides, +}); + +describe("OrderValue", () => { + describe("Read-only mode", () => { + it("should render shipping method name as text when not editable", () => { + // Arrange + const props = { + ...baseProps, + shippingMethodName: "Standard Shipping", + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Shipping")).toBeInTheDocument(); + expect(screen.getByText("Standard Shipping")).toBeInTheDocument(); + }); + + it("should render discounts as text when not editable", () => { + // Arrange + const props = { + ...baseProps, + discounts: [ + { + __typename: "OrderDiscount" as const, + id: "discount-1", + name: "Summer Sale", + amount: { __typename: "Money" as const, amount: 15, currency: "USD" }, + type: DiscountValueTypeEnum.FIXED, + valueType: DiscountValueTypeEnum.FIXED, + value: 15, + reason: null, + }, + ], + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Discount")).toBeInTheDocument(); + expect(screen.getByText("Summer Sale")).toBeInTheDocument(); + }); + }); + + describe("Editable mode - Shipping", () => { + it("should show 'No shipping address' when shipping address is not set", () => { + // Arrange + const props = createEditableProps({ + shippingAddress: null, + shippingMethods: shippingMethods, + isShippingRequired: true, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("No shipping address")).toBeInTheDocument(); + expect(screen.queryByText("Set shipping method")).not.toBeInTheDocument(); + }); + + it("should show 'No applicable shipping methods' when address is set but no methods available", () => { + // Arrange + const props = createEditableProps({ + shippingAddress, + shippingMethods: [], + isShippingRequired: false, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("No applicable shipping methods")).toBeInTheDocument(); + expect(screen.queryByText("Set shipping method")).not.toBeInTheDocument(); + }); + + it("should show 'Set shipping method' link when address and methods are available", () => { + // Arrange + const onShippingMethodEdit = jest.fn(); + const props = createEditableProps({ + shippingAddress, + shippingMethods, + isShippingRequired: true, + onShippingMethodEdit, + }); + + // Act + render( + + + , + ); + + // Assert + const setShippingLink = screen.getByText("Set shipping method"); + + expect(setShippingLink).toBeInTheDocument(); + expect(setShippingLink.tagName).toBe("A"); + }); + + it("should call onShippingMethodEdit when 'Set shipping method' link is clicked", async () => { + // Arrange + const onShippingMethodEdit = jest.fn(); + const props = createEditableProps({ + shippingAddress, + shippingMethods, + isShippingRequired: true, + onShippingMethodEdit, + }); + + // Act + render( + + + , + ); + + const setShippingLink = screen.getByText("Set shipping method"); + + await userEvent.click(setShippingLink); + + // Assert + expect(onShippingMethodEdit).toHaveBeenCalledTimes(1); + }); + + it("should show chosen shipping method as clickable link when alternatives exist", async () => { + // Arrange + const onShippingMethodEdit = jest.fn(); + const props = createEditableProps({ + shippingAddress, + shippingMethods, + shippingMethod, + shippingMethodName: "Standard Shipping", + isShippingRequired: true, + onShippingMethodEdit, + }); + + // Act + render( + + + , + ); + + // Assert + const methodLink = screen.getByText("Standard Shipping"); + + expect(methodLink).toBeInTheDocument(); + expect(methodLink.tagName).toBe("A"); + + await userEvent.click(methodLink); + expect(onShippingMethodEdit).toHaveBeenCalledTimes(1); + }); + + it("should show chosen shipping method as non-clickable text when no alternatives exist", () => { + // Arrange + const onShippingMethodEdit = jest.fn(); + const props = createEditableProps({ + shippingAddress, + shippingMethods: [], + shippingMethod, + shippingMethodName: "Standard Shipping", + isShippingRequired: false, + onShippingMethodEdit, + }); + + // Act + render( + + + , + ); + + // Assert + const methodText = screen.getByText("Standard Shipping"); + + expect(methodText).toBeInTheDocument(); + expect(methodText.tagName).toBe("SPAN"); + expect(methodText).toHaveAttribute("title", "No alternative shipping methods available"); + }); + + it("should prioritize 'No shipping address' over 'No applicable shipping methods'", () => { + // Arrange + const props = createEditableProps({ + shippingAddress: null, + shippingMethods: [], + isShippingRequired: false, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("No shipping address")).toBeInTheDocument(); + expect(screen.queryByText("No applicable shipping methods")).not.toBeInTheDocument(); + }); + }); + + describe("Editable mode - Discount", () => { + it("should show 'Add Discount' link when no discount is set", () => { + // Arrange + const props = createEditableProps({ + shippingAddress, + shippingMethods, + orderDiscount: undefined, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Add Discount")).toBeInTheDocument(); + }); + + it("should call openDialog when 'Add Discount' link is clicked", async () => { + // Arrange + const openDialog = jest.fn(); + const props = createEditableProps({ + shippingAddress, + shippingMethods, + orderDiscount: undefined, + openDialog, + }); + + // Act + render( + + + , + ); + + const addDiscountLink = screen.getByText("Add Discount"); + + await userEvent.click(addDiscountLink); + + // Assert + expect(openDialog).toHaveBeenCalledTimes(1); + }); + + it("should show percentage discount value when discount is percentage type", () => { + // Arrange + const orderDiscount: OrderDiscountData = { + id: "discount-1", + value: 10, + calculationMode: DiscountValueTypeEnum.PERCENTAGE, + amount: { __typename: "Money", amount: 11, currency: "USD" }, + reason: "Loyalty discount", + }; + const props = createEditableProps({ + shippingAddress, + shippingMethods, + orderDiscount, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Discount")).toBeInTheDocument(); + expect(screen.getByText("10%")).toBeInTheDocument(); + }); + + it("should show fixed discount value when discount is fixed type", () => { + // Arrange + const orderDiscount: OrderDiscountData = { + id: "discount-1", + value: 15, + calculationMode: DiscountValueTypeEnum.FIXED, + amount: { __typename: "Money", amount: 15, currency: "USD" }, + reason: "Special offer", + }; + const props = createEditableProps({ + shippingAddress, + shippingMethods, + orderDiscount, + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Discount")).toBeInTheDocument(); + expect(screen.getByText("15.00")).toBeInTheDocument(); + }); + + it("should show discount reason as tooltip on existing discount", () => { + // Arrange + const orderDiscount: OrderDiscountData = { + id: "discount-1", + value: 10, + calculationMode: DiscountValueTypeEnum.PERCENTAGE, + amount: { __typename: "Money", amount: 11, currency: "USD" }, + reason: "Loyalty discount", + }; + const props = createEditableProps({ + shippingAddress, + shippingMethods, + orderDiscount, + }); + + // Act + render( + + + , + ); + + // Assert + const discountLink = screen.getByText("10%"); + + expect(discountLink).toHaveAttribute("title", "Loyalty discount"); + }); + }); + + describe("Gift cards", () => { + it("should render gift card row when gift cards are present", () => { + // Arrange + const props = { + ...baseProps, + giftCardsAmount: 25, + usedGiftCards: [ + { + __typename: "GiftCard" as const, + id: "gc-1", + last4CodeChars: "ABCD", + currentBalance: { __typename: "Money" as const, amount: 25, currency: "USD" }, + events: [], + }, + ], + }; + + // Act + render( + + + , + ); + + // Assert - query by the title attribute which is reliably set + expect(screen.getByTitle("Gift card amount used")).toBeInTheDocument(); + expect(screen.getByText("-25")).toBeInTheDocument(); + }); + + it("should render multiple gift cards row", () => { + // Arrange + const props = { + ...baseProps, + giftCardsAmount: 50, + usedGiftCards: [ + { + __typename: "GiftCard" as const, + id: "gc-1", + last4CodeChars: "ABCD", + currentBalance: { __typename: "Money" as const, amount: 25, currency: "USD" }, + events: [], + }, + { + __typename: "GiftCard" as const, + id: "gc-2", + last4CodeChars: "WXYZ", + currentBalance: { __typename: "Money" as const, amount: 25, currency: "USD" }, + events: [], + }, + ], + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByTitle("Gift card amount used")).toBeInTheDocument(); + expect(screen.getByText("-50")).toBeInTheDocument(); + }); + + it("should not render gift card section when no gift cards used", () => { + // Arrange + const props = { + ...baseProps, + giftCardsAmount: 0, + usedGiftCards: null, + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.queryByTitle("Gift card amount used")).not.toBeInTheDocument(); + }); + }); + + describe("Taxes display", () => { + it("should show taxes as included when displayGrossPrices is true", () => { + // Arrange + const props = { + ...baseProps, + displayGrossPrices: true, + orderTotal: { + ...baseProps.orderTotal, + tax: { __typename: "Money" as const, amount: 10, currency: "USD" }, + }, + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Taxes")).toBeInTheDocument(); + expect(screen.getByText("(included)")).toBeInTheDocument(); + }); + + it("should show taxes without 'included' when displayGrossPrices is false", () => { + // Arrange + const props = { + ...baseProps, + displayGrossPrices: false, + orderTotal: { + ...baseProps.orderTotal, + tax: { __typename: "Money" as const, amount: 10, currency: "USD" }, + }, + }; + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("Taxes")).toBeInTheDocument(); + expect(screen.queryByText("(included)")).not.toBeInTheDocument(); + }); + }); +}); From f26dfd526e8d9a5b66b08222c33e6f6edf2bc2e6 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 16:38:12 +0100 Subject: [PATCH 22/86] Extract messages --- locale/defaultMessages.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index c5ebfea2744..495bb18754d 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1935,6 +1935,10 @@ "context": "attribute properties regarding storefront", "string": "Storefront Properties" }, + "Ai4XSg": { + "context": "shown when shipping address is required but not set", + "string": "No shipping address" + }, "AiurXc": { "string": "Outstanding authorized" }, @@ -2105,10 +2109,6 @@ "context": "header", "string": "Address Information" }, - "BjxQ3u": { - "context": "shown when shipping address is required but not set", - "string": "No shipping address" - }, "Bkxrhw": { "context": "no webhooks message", "string": "No webhooks found" @@ -2429,6 +2429,10 @@ "Dd0Dwl": { "string": "Go to promotions" }, + "Dg4Y2p": { + "context": "set shipping method link when no shipping method selected", + "string": "Set shipping method" + }, "Dgp38J": { "context": "checkbox label", "string": "Restrict order value" @@ -2793,6 +2797,10 @@ "context": "checkbox description", "string": "If selected, this will add all of the countries not selected to other shipping zones" }, + "G+MtyG": { + "context": "no shipping methods available", + "string": "No applicable shipping methods" + }, "G/pgG3": { "context": "dialog header", "string": "Select a channel" @@ -3769,10 +3777,6 @@ "context": "WarehouseSettings disabled warehouse description", "string": "If selected customer won't be able to choose this warehouse as pickup point" }, - "M9LXb5": { - "context": "no shipping methods available", - "string": "No applicable shipping methods" - }, "MAsLIT": { "context": "custom app token key", "string": "Key" @@ -10074,10 +10078,6 @@ "context": "swatch attribute type", "string": "Swatch type" }, - "zuWUXf": { - "context": "set shipping method link when no shipping method selected", - "string": "Set shipping method" - }, "zv9OGI": { "string": "No models found" }, From 94b676f6e358e1472ed7b1e3943c272319810ae6 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 18:15:05 +0100 Subject: [PATCH 23/86] Fix liniting errors --- src/orders/components/OrderSummary/OrderValue.test.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.test.tsx b/src/orders/components/OrderSummary/OrderValue.test.tsx index abaefc19b79..4840d0045ce 100644 --- a/src/orders/components/OrderSummary/OrderValue.test.tsx +++ b/src/orders/components/OrderSummary/OrderValue.test.tsx @@ -1,4 +1,4 @@ -import { DiscountValueTypeEnum, OrderDetailsFragment } from "@dashboard/graphql"; +import { DiscountValueTypeEnum, OrderDetailsFragment, OrderDiscountType } from "@dashboard/graphql"; import { prepareMoney } from "@dashboard/orders/fixtures"; import { OrderDiscountData } from "@dashboard/products/components/OrderDiscountProviders/types"; import Wrapper from "@test/wrapper"; @@ -133,8 +133,8 @@ describe("OrderValue", () => { id: "discount-1", name: "Summer Sale", amount: { __typename: "Money" as const, amount: 15, currency: "USD" }, - type: DiscountValueTypeEnum.FIXED, - valueType: DiscountValueTypeEnum.FIXED, + type: OrderDiscountType.MANUAL, + calculationMode: DiscountValueTypeEnum.FIXED, value: 15, reason: null, }, @@ -369,7 +369,6 @@ describe("OrderValue", () => { it("should show percentage discount value when discount is percentage type", () => { // Arrange const orderDiscount: OrderDiscountData = { - id: "discount-1", value: 10, calculationMode: DiscountValueTypeEnum.PERCENTAGE, amount: { __typename: "Money", amount: 11, currency: "USD" }, @@ -396,7 +395,6 @@ describe("OrderValue", () => { it("should show fixed discount value when discount is fixed type", () => { // Arrange const orderDiscount: OrderDiscountData = { - id: "discount-1", value: 15, calculationMode: DiscountValueTypeEnum.FIXED, amount: { __typename: "Money", amount: 15, currency: "USD" }, @@ -423,7 +421,6 @@ describe("OrderValue", () => { it("should show discount reason as tooltip on existing discount", () => { // Arrange const orderDiscount: OrderDiscountData = { - id: "discount-1", value: 10, calculationMode: DiscountValueTypeEnum.PERCENTAGE, amount: { __typename: "Money", amount: 11, currency: "USD" }, From ee0aea14bdf0d32c208bf1c8db87ae69f1230b61 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 20:09:55 +0100 Subject: [PATCH 24/86] Let users remove the selected shipping method in Drafts --- locale/defaultMessages.json | 4 ++ .../OrderShippingMethodEditDialog.test.tsx | 57 +++++++++++++++++++ .../OrderShippingMethodEditDialog.tsx | 53 +++++++++++++++-- .../OrderDetails/OrderDraftDetails/index.tsx | 1 + 4 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 495bb18754d..bcc0de53dd9 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -6633,6 +6633,10 @@ "dWK/Ck": { "string": "Choose countries, you want voucher to be limited to, from the list below" }, + "dYc+5u": { + "context": "no shipping method option", + "string": "No shipping method" + }, "da1ebU": { "string": "Product type" }, diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx new file mode 100644 index 00000000000..9db84e37a0c --- /dev/null +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx @@ -0,0 +1,57 @@ +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { order } from "@dashboard/orders/fixtures"; +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; + +import OrderShippingMethodEditDialog from "./OrderShippingMethodEditDialog"; + +describe("OrderShippingMethodEditDialog", () => { + const shippingMethods = order("").shippingMethods!; + const defaultProps = { + confirmButtonState: "default" as ConfirmButtonTransitionState, + errors: [], + open: true, + shippingMethod: shippingMethods[1].id, + shippingMethodName: shippingMethods[1].name, + shippingPrice: shippingMethods[1].price, + shippingMethods, + onClose: jest.fn(), + onSubmit: jest.fn(), + }; + + it("renders with available shipping methods", () => { + render( + + + , + ); + + expect(screen.getByText("Edit Shipping Method")).toBeInTheDocument(); + + expect(screen.getByTestId("shipping-method-select")).toBeInTheDocument(); + }); + + it("renders 'No shipping method' option when isClearable is true", () => { + render( + + + , + ); + + expect(screen.getByText("No shipping method")).toBeInTheDocument(); + }); + + it("renders current shipping method name when selected", () => { + render( + + + , + ); + + expect(screen.getByText(shippingMethods[1].name)).toBeInTheDocument(); + }); +}); diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx index 8c6f887f6bb..af6cb7506a8 100644 --- a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx @@ -22,14 +22,17 @@ interface OrderShippingMethodEditDialogProps { confirmButtonState: ConfirmButtonTransitionState; errors: OrderErrorFragment[]; open: boolean; - shippingMethod: string; + shippingMethod: string | null | undefined; shippingMethodName?: string; shippingPrice?: OrderDetailsFragment["shippingPrice"]; shippingMethods?: OrderDetailsFragment["shippingMethods"]; onClose: () => any; - onSubmit?: (data: FormData) => any; + onSubmit?: (data: { shippingMethod: string | null }) => any; + isClearable?: boolean; } +const NO_SHIPPING_METHOD_ID = "no-shipping-method-selection"; + const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps) => { const { confirmButtonState, @@ -41,6 +44,7 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps shippingMethods, onClose, onSubmit, + isClearable = false, } = props; const errors = useModalDialogErrors(apiErrors, open); const intl = useIntl(); @@ -116,16 +120,54 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps ] : []; - const choices = [...unavailableMethodOption, ...availableChoices]; + const noShippingOption = { + label: ( + + + + + + + + ), + value: NO_SHIPPING_METHOD_ID, + disabled: false, + }; + + const choices = [ + ...(isClearable ? [noShippingOption] : []), + ...unavailableMethodOption, + ...availableChoices, + ]; const initialForm: FormData = { - shippingMethod: currentMethodInChoices ? shippingMethod : "", + shippingMethod: + currentMethodInChoices || unavailableMethodOption.length > 0 + ? (shippingMethod as string) + : NO_SHIPPING_METHOD_ID, + }; + + const handleSubmit = (data: FormData) => { + if (onSubmit) { + onSubmit({ + shippingMethod: data.shippingMethod === NO_SHIPPING_METHOD_ID ? null : data.shippingMethod, + }); + } }; return ( {open && ( -
+ {({ change, data, submit }) => ( <> @@ -178,7 +220,6 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps data-test-id="confirm-button" transitionState={confirmButtonState} onClick={submit} - disabled={!data.shippingMethod} > diff --git a/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx b/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx index ddcd9a51680..c64b1aab75a 100644 --- a/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderDraftDetails/index.tsx @@ -257,6 +257,7 @@ export const OrderDraftDetails = ({ Date: Tue, 9 Dec 2025 20:16:59 +0100 Subject: [PATCH 25/86] Extract messages --- locale/defaultMessages.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index bcc0de53dd9..0acb6a0fef5 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -1492,6 +1492,10 @@ "context": "model", "string": "will be visible from {date}" }, + "7ZKQU3": { + "context": "no shipping method option", + "string": "No shipping method" + }, "7dhhzL": { "context": "bulk issue gift cards dialog title", "string": "Bulk Issue Gift Cards" @@ -6633,10 +6637,6 @@ "dWK/Ck": { "string": "Choose countries, you want voucher to be limited to, from the list below" }, - "dYc+5u": { - "context": "no shipping method option", - "string": "No shipping method" - }, "da1ebU": { "string": "Product type" }, From 3210bc122de8f8e03b24d55b17fc1ee03d6d23f7 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 9 Dec 2025 20:18:18 +0100 Subject: [PATCH 26/86] Types --- .../OrderShippingMethodEditDialog.test.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx index 9db84e37a0c..29af83e9fca 100644 --- a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.test.tsx @@ -7,13 +7,32 @@ import OrderShippingMethodEditDialog from "./OrderShippingMethodEditDialog"; describe("OrderShippingMethodEditDialog", () => { const shippingMethods = order("").shippingMethods!; + const mockShippingPrice = { + __typename: "TaxedMoney" as const, + gross: { + __typename: "Money" as const, + amount: 10, + currency: "USD", + }, + net: { + __typename: "Money" as const, + amount: 10, + currency: "USD", + }, + tax: { + __typename: "Money" as const, + amount: 0, + currency: "USD", + }, + }; + const defaultProps = { confirmButtonState: "default" as ConfirmButtonTransitionState, errors: [], open: true, shippingMethod: shippingMethods[1].id, shippingMethodName: shippingMethods[1].name, - shippingPrice: shippingMethods[1].price, + shippingPrice: mockShippingPrice, shippingMethods, onClose: jest.fn(), onSubmit: jest.fn(), From dd3ad2cde79541d02b759600c5aaf948d1429e65 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 10 Dec 2025 23:11:56 +0100 Subject: [PATCH 27/86] Add new unified OrderCaptureDialog with amount options - Create new OrderCaptureDialog component using macaw-ui-next - Supports three capture options: order total, authorized amount, custom - Shows contextual warnings when authorized differs from total - Integrate with both Legacy Payments API and Transactions API flows - Include development overrides for testing (to be removed) --- src/components/Callout/Callout.tsx | 100 +-- src/components/PriceField/utils.ts | 37 ++ .../OrderCaptureDialog/OrderCaptureDialog.tsx | 576 ++++++++++++++++++ .../components/OrderCaptureDialog/messages.ts | 146 +++++ .../OrderSummary/LegacyPaymentsApiButtons.tsx | 2 +- .../components/OrderSummary/OrderSummary.tsx | 3 + .../OrderDetails/OrderNormalDetails/index.tsx | 52 +- .../OrderUnconfirmedDetails/index.tsx | 52 +- 8 files changed, 907 insertions(+), 61 deletions(-) create mode 100644 src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx create mode 100644 src/orders/components/OrderCaptureDialog/messages.ts diff --git a/src/components/Callout/Callout.tsx b/src/components/Callout/Callout.tsx index dbf42137d42..c4493ef5a8d 100644 --- a/src/components/Callout/Callout.tsx +++ b/src/components/Callout/Callout.tsx @@ -1,58 +1,66 @@ -import { ExclamationIcon } from "@dashboard/icons/ExclamationIcon"; -import { Box } from "@saleor/macaw-ui-next"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { getStatusColor, PillStatusType } from "@dashboard/misc"; +import { Box, Text, useTheme } from "@saleor/macaw-ui-next"; +import { AlertTriangle, CircleAlert, Info, LucideIcon } from "lucide-react"; import { ReactNode } from "react"; -import { DashboardCard } from "../Card"; +type CalloutType = "info" | "warning" | "error"; -type CalloutType = "info" | "warning"; +interface CalloutStyles { + status: PillStatusType; + iconColor: "warning1" | "critical1" | "default1"; + Icon: LucideIcon; +} -const warningStylesBox = { - backgroundColor: "warning1", - borderColor: "warning1", -} as const; - -const warningStylesIcon = { - color: "warning1", -} as const; - -const gridTemplate = ` - "icon title" - "empty content" -`; +const calloutStylesMap: Record = { + warning: { + status: "warning", + iconColor: "warning1", + Icon: CircleAlert, + }, + error: { + status: "error", + iconColor: "critical1", + Icon: AlertTriangle, + }, + info: { + status: "neutral", + iconColor: "default1", + Icon: Info, + }, +}; -export const Callout = ({ - children, - title, - type, -}: { - children: ReactNode; +interface CalloutProps { + children?: ReactNode; title: ReactNode; type: CalloutType; -}) => { - const boxStyles = type === "warning" ? warningStylesBox : null; - const iconStyles = type === "warning" ? warningStylesIcon : null; +} + +export const Callout = ({ children, title, type }: CalloutProps): JSX.Element => { + const { theme: currentTheme } = useTheme(); + const { status, iconColor, Icon } = calloutStylesMap[type]; + const backgroundColor = getStatusColor({ status, currentTheme }).base; return ( - - - - - - {title} - - - {children} - - + + + + + {title} + {children && ( + + {children} + + )} + + ); }; diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index a966bd3073a..bcd41c3597a 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -32,3 +32,40 @@ export const getCurrencyDecimalPoints = (currency?: string) => { export const findPriceSeparator = (input: string) => SEPARATOR_CHARACTERS.find(separator => input.includes(separator)); + +/** + * Normalizes decimal separator to JavaScript standard (dot). + * Converts comma to dot for locales that use comma as decimal separator. + */ +export const normalizeDecimalSeparator = (value: string): string => value.replace(",", "."); + +/** + * Parses a decimal string value to a number, handling locale-specific separators. + * Returns 0 if the value cannot be parsed. + */ +export const parseDecimalValue = (value: string): number => + parseFloat(normalizeDecimalSeparator(value)) || 0; + +/** + * Limits decimal places in a string value, preserving the user's original separator. + * Useful for input validation while typing. + */ +export const limitDecimalPlaces = (value: string, maxDecimalPlaces: number): string => { + const normalized = normalizeDecimalSeparator(value); + const separator = value.includes(",") ? "," : "."; + const [integerPart, decimalPart] = normalized.split("."); + + if (!decimalPart) { + return value; + } + + if (maxDecimalPlaces === 0) { + return integerPart; + } + + if (decimalPart.length > maxDecimalPlaces) { + return `${integerPart}${separator}${decimalPart.slice(0, maxDecimalPlaces)}`; + } + + return value; +}; diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx new file mode 100644 index 00000000000..b9523ac2571 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -0,0 +1,576 @@ +import BackButton from "@dashboard/components/BackButton"; +import { Callout } from "@dashboard/components/Callout/Callout"; +import { ConfirmButton, ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; +import { DashboardModal } from "@dashboard/components/Modal"; +import Money from "@dashboard/components/Money"; +import { Pill } from "@dashboard/components/Pill"; +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + parseDecimalValue, +} from "@dashboard/components/PriceField/utils"; +import { OrderErrorFragment, TransactionRequestActionErrorFragment } from "@dashboard/graphql"; +import getOrderErrorMessage from "@dashboard/utils/errors/order"; +import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transaction"; +import { IMoney } from "@dashboard/utils/intl"; +import { Box, Input, RadioGroup, Text } from "@saleor/macaw-ui-next"; +import { AlertTriangle, Box as BoxIcon, CheckCircle2, CircleAlert, CreditCard } from "lucide-react"; +import { ChangeEvent, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { messages } from "./messages"; + +type CaptureError = OrderErrorFragment | TransactionRequestActionErrorFragment; + +const isTransactionError = ( + error: CaptureError, +): error is TransactionRequestActionErrorFragment => { + // TransactionRequestActionErrorFragment has a different __typename + return error.__typename === "TransactionRequestActionError"; +}; + +export type CaptureAmountOption = "orderTotal" | "custom"; + +type AuthorizationStatus = "full" | "partial" | "none" | "charged"; + +export interface OrderCaptureDialogProps { + open: boolean; + confirmButtonState: ConfirmButtonTransitionState; + orderTotal: IMoney; + authorizedAmount: IMoney; + /** Amount already charged/captured - used for display */ + chargedAmount?: IMoney; + /** + * Order's total balance (for multi-transaction orders). + * Negative = customer owes money, Positive = overpaid. + * When provided, used instead of (orderTotal - chargedAmount) for remaining calculation. + */ + orderBalance?: IMoney; + /** When true, shows "Transaction authorized" instead of "Authorized" */ + isTransaction?: boolean; + /** Server errors from the capture mutation (supports both Legacy and Transactions API errors) */ + errors?: CaptureError[]; + onClose: () => void; + onSubmit: (amount: number) => void; +} + +export const OrderCaptureDialog = ({ + open, + confirmButtonState, + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, + isTransaction: _isTransaction = false, + errors = [], + onClose, + onSubmit, +}: OrderCaptureDialogProps): JSX.Element => { + const intl = useIntl(); + + const totalAmount = orderTotal.amount; + const authAmount = authorizedAmount.amount; // Available to capture (bucket model) + const alreadyCharged = chargedAmount?.amount ?? 0; + const currency = orderTotal.currency; + + // With bucket model: authorizedAmount = what's available to capture + // (funds move from authorizedAmount to chargedAmount when captured) + const availableToCapture = authAmount; + + // Calculate what customer still owes: + // - If orderBalance provided (multi-transaction): use it (negative balance = owes money) + // - Otherwise: simple calculation from order total minus charged + const remainingToPay = orderBalance + ? Math.max(0, -orderBalance.amount) // Convert negative balance to positive amount owed + : totalAmount - alreadyCharged; + + // Order-wide captured amount (for display in Order section) + // = order total minus what's still owed + const orderTotalCaptured = totalAmount - remainingToPay; + + // Determine authorization status + const getAuthorizationStatus = (): AuthorizationStatus => { + // Check if fully charged first (nothing left to pay) + if (remainingToPay <= 0) { + return "charged"; + } + + if (availableToCapture <= 0) { + return "none"; + } + + if (availableToCapture >= remainingToPay) { + return "full"; + } + + return "partial"; + }; + + const authStatus = getAuthorizationStatus(); + const maxCapturable = Math.max(0, availableToCapture); + const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; + const shortfall = remainingToPay - availableToCapture; + + // Default selection based on status + const getDefaultOption = (): CaptureAmountOption => { + // For full and partial, default to the first option + // For "none" or "charged" we can't select anything meaningful + if (authStatus === "full" || authStatus === "partial") { + return "orderTotal"; + } + + return "custom"; + }; + + const getDefaultCustomAmount = (): string => { + if (authStatus === "none" || authStatus === "charged") { + return "0"; + } + + if (authStatus === "partial") { + // Default to max capturable (remaining auth) + return String(availableToCapture); + } + + // Default to remaining amount to pay + return String(remainingToPay); + }; + + const [selectedOption, setSelectedOption] = useState(getDefaultOption); + const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + + // Get max decimal places for this currency (e.g., 2 for USD, 0 for JPY, 3 for KWD) + const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); + + const handleCustomAmountChange = (e: ChangeEvent): void => { + const limitedValue = limitDecimalPlaces(e.target.value, maxDecimalPlaces); + + setCustomAmount(limitedValue); + }; + + const getSelectedAmount = (): number => { + switch (selectedOption) { + case "orderTotal": + // For partial auth, capture max available; for full, capture remaining balance + return authStatus === "partial" ? availableToCapture : remainingToPay; + case "custom": + return parseDecimalValue(customAmount); + } + }; + + const selectedAmount = getSelectedAmount(); + const customAmountValue = parseDecimalValue(customAmount); + const isCustomAmountInRange = customAmountValue > 0 && customAmountValue <= maxCapturable; + const isCustomAmountValid = selectedOption !== "custom" || isCustomAmountInRange; + const showCustomAmountError = + selectedOption === "custom" && + authStatus !== "none" && + authStatus !== "charged" && + !isCustomAmountInRange; + const canSubmit = + authStatus !== "none" && authStatus !== "charged" && isCustomAmountValid && selectedAmount > 0; + + const handleSubmit = (): void => { + if (canSubmit) { + onSubmit(selectedAmount); + } + }; + + const formatMoney = (amount: number): JSX.Element => ( + + + + ); + + // Calculate predicted outcome status after capture (order-wide) + type OutcomeStatus = "fullyCharged" | "partiallyCharged" | "overcharged"; + + const getOutcomeStatus = (): OutcomeStatus => { + const totalAfterCapture = orderTotalCaptured + selectedAmount; + + if (totalAfterCapture > totalAmount) { + return "overcharged"; + } else if (totalAfterCapture >= totalAmount) { + return "fullyCharged"; + } else { + return "partiallyCharged"; + } + }; + + const outcomeStatus = getOutcomeStatus(); + + const getStatusPill = (): JSX.Element => { + switch (authStatus) { + case "charged": + return ( + } + /> + ); + case "full": + return ( + } + /> + ); + case "partial": + return ( + } + /> + ); + case "none": + return ( + } + /> + ); + } + }; + + type AuthorizationColor = "success1" | "warning1" | "critical1"; + + const authStatusColorMap: Record = { + charged: "success1", + full: "success1", + partial: "warning1", + none: "critical1", + }; + + const authorizedAmountColor = authStatusColorMap[authStatus]; + + return ( + + + + + + {getStatusPill()} + + + + + {/* Summary box with order and payment sections */} + + {/* Order section */} + + + + + + + + + {formatMoney(totalAmount)} + + {orderTotalCaptured > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(orderTotalCaptured)} + + )} + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(remainingToPay)} + + + + {/* Transaction section */} + + + + + + + + + + + {formatMoney(availableToCapture)} + + + {alreadyCharged > 0 && ( + + + {/* Spacer to align with icon above */} + + + + + {formatMoney(alreadyCharged)} + + )} + + + {/* Warning/Error messages */} + {authStatus === "partial" && ( + + {formatMoney(shortfall)}, + }} + /> + + } + /> + )} + + {authStatus === "none" && ( + + {formatMoney(remainingToPay)}, + }} + /> + + } + /> + )} + + + + {/* Radio options - primary section */} + + + + + + setSelectedOption(value)} + > + + {/* Order Total / Remaining Balance / Remaining Max option */} + + + + + 0 + ? messages.remainingBalance + : messages.optionOrderTotal)} + /> + + + + {formatMoney(authStatus === "partial" ? availableToCapture : remainingToPay)} + + + {canCaptureOrderTotal && ( + + + + + + )} + + + {/* Custom amount option */} + + + + + + + + + + + + + {currency} + + } + /> + + + + + + + + + + + + {/* Outcome prediction */} + {canSubmit && selectedAmount > 0 && ( + + + {outcomeStatus === "overcharged" && ( + + ), + }} + /> + )} + {outcomeStatus === "fullyCharged" && ( + + ), + }} + /> + )} + {outcomeStatus === "partiallyCharged" && ( + + ), + }} + /> + )} + + + )} + + {errors.length > 0 && ( + + {errors.map((error, index) => ( + + {isTransactionError(error) + ? getOrderTransactionErrorMessage(error, intl) + : getOrderErrorMessage(error, intl)} + + ))} + + )} + + + + + + + + + + ); +}; + +OrderCaptureDialog.displayName = "OrderCaptureDialog"; diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts new file mode 100644 index 00000000000..21416d288c6 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -0,0 +1,146 @@ +import { defineMessages } from "react-intl"; + +export const messages = defineMessages({ + title: { + id: "0f6YvV", + defaultMessage: "Capture Payment", + description: "dialog title", + }, + statusFullyAuthorized: { + id: "HwIhau", + defaultMessage: "Fully Authorized", + description: "status pill for fully authorized payment", + }, + statusPartial: { + id: "ZUYQ+C", + defaultMessage: "Partial authorisation", + description: "status pill for partial authorization", + }, + statusNoAuthorization: { + id: "mxGY7T", + defaultMessage: "No Authorization", + description: "status pill for no authorization", + }, + statusFullyCaptured: { + id: "L8J/jr", + defaultMessage: "Fully Captured", + description: "status pill when order is fully paid", + }, + orderTotal: { + id: "4YyeCx", + defaultMessage: "Order total", + description: "label for order total amount", + }, + authorized: { + id: "U0IK0G", + defaultMessage: "Authorized", + description: "label for authorized amount", + }, + transactionAuthorized: { + id: "H0eCbU", + defaultMessage: "Transaction authorized", + description: "label for transaction authorized amount", + }, + capturedSoFar: { + id: "0YOedO", + defaultMessage: "Captured so far", + description: "label for already charged amount", + }, + balanceDue: { + id: "qlfssi", + defaultMessage: "Balance due", + description: "label for remaining amount customer owes", + }, + availableToCapture: { + id: "MhlYkx", + defaultMessage: "Available to capture (authorized)", + description: "label for available authorization amount", + }, + transactionCaptured: { + id: "R/YHMH", + defaultMessage: "Already captured", + description: "label for amount already captured from this transaction", + }, + remainingBalance: { + id: "OUMqG1", + defaultMessage: "Remaining balance", + description: "label for remaining balance to capture", + }, + remainingMax: { + id: "jhyt3I", + defaultMessage: "Remaining max (authorized)", + description: "label for max capturable amount when partial authorization", + }, + selectAmount: { + id: "XrliJg", + defaultMessage: "Select amount to capture:", + description: "label for amount selection", + }, + optionOrderTotal: { + id: "tS2K/N", + defaultMessage: "Order total", + description: "radio option for capturing order total", + }, + optionOrderTotalHint: { + id: "v8e93p", + defaultMessage: "Matches what customer owes", + description: "hint for order total option", + }, + optionCustom: { + id: "IU1lif", + defaultMessage: "Custom amount", + description: "radio option for custom capture amount", + }, + customAmountMax: { + id: "Mm/Stj", + defaultMessage: "Max: {amount}", + description: "hint showing maximum allowed custom amount", + }, + captureButton: { + id: "bRXgSC", + defaultMessage: "Capture {amount}", + description: "capture button with amount", + }, + warningPartialAuthorization: { + id: "8JEG80", + defaultMessage: + "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment.", + description: "warning when authorized is less than total", + }, + errorNoAuthorization: { + id: "SnV3LR", + defaultMessage: + "No payment has been authorized for this order. The full amount of {amount} cannot be captured.", + description: "error when no authorization exists", + }, + outcomeFullyCharged: { + id: "VwCTbx", + defaultMessage: "This will result in a {status} order", + description: "outcome prediction when order will be fully charged", + }, + outcomePartiallyCharged: { + id: "ycg2RR", + defaultMessage: "This will result in a {status} order", + description: "outcome prediction when order will be partially charged", + }, + outcomeOvercharged: { + id: "DXaxpH", + defaultMessage: "This will result in an {status} order", + description: "outcome prediction when order will be overcharged", + }, + statusFullyCapturedPill: { + id: "G9y5Ze", + defaultMessage: "Fully captured", + description: "pill status for fully captured outcome", + }, + statusPartiallyCapturedPill: { + id: "BJRu4V", + defaultMessage: "Partially captured", + description: "pill status for partially captured outcome", + }, + statusOvercapturedPill: { + id: "u7ShY+", + defaultMessage: "Overcaptured", + description: "pill status for overcaptured outcome", + }, +}); diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index 0a974c3b9ca..9fe777dc7ec 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -24,7 +24,7 @@ export const LegacyPaymentsApiButtons = ({ onLegacyPaymentsApiCapture, onLegacyPaymentsApiRefund, onLegacyPaymentsApiVoid, -}: Props) => { +}: Props): JSX.Element | null => { const intl = useIntl(); const showButtons = diff --git a/src/orders/components/OrderSummary/OrderSummary.tsx b/src/orders/components/OrderSummary/OrderSummary.tsx index c8ba4b9a307..b0d19d953ed 100644 --- a/src/orders/components/OrderSummary/OrderSummary.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.tsx @@ -78,6 +78,9 @@ export const OrderSummary = (props: Props) => { onLegacyPaymentsApiCapture: _onLegacyPaymentsApiCapture, onLegacyPaymentsApiRefund: _onLegacyPaymentsApiRefund, onLegacyPaymentsApiVoid: _onLegacyPaymentsApiVoid, + onTestCaptureFull: _onTestCaptureFull, + onTestCapturePartial: _onTestCapturePartial, + onTestCaptureNone: _onTestCaptureNone, ...boxProps } = restProps as any; diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 0315f611185..218c30de4f0 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -15,6 +15,7 @@ import { OrderTransactionRequestActionMutationVariables, OrderUpdateMutation, OrderUpdateMutationVariables, + TransactionActionEnum, useCustomerAddressesQuery, useWarehouseListQuery, } from "@dashboard/graphql"; @@ -53,11 +54,11 @@ import { customerUrl } from "../../../../customers/urls"; import { productUrl } from "../../../../products/urls"; import OrderAddressFields from "../../../components/OrderAddressFields/OrderAddressFields"; import OrderCancelDialog from "../../../components/OrderCancelDialog"; +import { OrderCaptureDialog } from "../../../components/OrderCaptureDialog/OrderCaptureDialog"; import OrderDetailsPage from "../../../components/OrderDetailsPage/OrderDetailsPage"; import OrderFulfillmentCancelDialog from "../../../components/OrderFulfillmentCancelDialog"; import OrderFulfillmentTrackingDialog from "../../../components/OrderFulfillmentTrackingDialog"; import OrderMarkAsPaidDialog from "../../../components/OrderMarkAsPaidDialog/OrderMarkAsPaidDialog"; -import OrderPaymentDialog from "../../../components/OrderPaymentDialog"; import OrderPaymentVoidDialog from "../../../components/OrderPaymentVoidDialog"; import { orderFulfillUrl, @@ -303,10 +304,46 @@ export const OrderNormalDetails = ({ }) } /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-action" && params.type === TransactionActionEnum.CHARGE && ( + t.id === params.id)?.authorizedAmount ?? { + amount: 0, + currency: "USD", + } + } + chargedAmount={ + order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? { + amount: 0, + currency: "USD", + } + } + orderBalance={order?.totalBalance ?? { amount: 0, currency: "USD" }} + isTransaction + open={true} + onClose={closeModal} + onSubmit={amount => + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderTransactionAction @@ -354,15 +391,16 @@ export const OrderNormalDetails = ({ onClose={closeModal} onConfirm={() => orderVoid.mutate({ id })} /> - + onSubmit={amount => orderPaymentCapture.mutate({ - ...variables, + amount, id, }) } diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index c5fab507284..2b249a7b740 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -14,6 +14,7 @@ import { OrderTransactionRequestActionMutationVariables, OrderUpdateMutation, OrderUpdateMutationVariables, + TransactionActionEnum, useCustomerAddressesQuery, useWarehouseListQuery, } from "@dashboard/graphql"; @@ -46,11 +47,11 @@ import { import { productUrl } from "../../../../products/urls"; import OrderAddressFields from "../../../components/OrderAddressFields/OrderAddressFields"; import OrderCancelDialog from "../../../components/OrderCancelDialog"; +import { OrderCaptureDialog } from "../../../components/OrderCaptureDialog/OrderCaptureDialog"; import OrderDetailsPage from "../../../components/OrderDetailsPage/OrderDetailsPage"; import OrderFulfillmentCancelDialog from "../../../components/OrderFulfillmentCancelDialog"; import OrderFulfillmentTrackingDialog from "../../../components/OrderFulfillmentTrackingDialog"; import OrderMarkAsPaidDialog from "../../../components/OrderMarkAsPaidDialog/OrderMarkAsPaidDialog"; -import OrderPaymentDialog from "../../../components/OrderPaymentDialog"; import OrderPaymentVoidDialog from "../../../components/OrderPaymentVoidDialog"; import OrderProductAddDialog from "../../../components/OrderProductAddDialog"; import OrderShippingMethodEditDialog from "../../../components/OrderShippingMethodEditDialog"; @@ -366,10 +367,46 @@ export const OrderUnconfirmedDetails = ({ transactionReference={transactionReference} handleTransactionReference={({ target }) => setTransactionReference(target.value)} /> + {/* Transaction Capture Dialog - for CHARGE action */} + {params.action === "transaction-action" && params.type === TransactionActionEnum.CHARGE && ( + t.id === params.id)?.authorizedAmount ?? { + amount: 0, + currency: "USD", + } + } + chargedAmount={ + order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? { + amount: 0, + currency: "USD", + } + } + orderBalance={order?.totalBalance ?? { amount: 0, currency: "USD" }} + isTransaction + open={true} + onClose={closeModal} + onSubmit={amount => + orderTransactionAction + .mutate({ + action: params.type, + transactionId: params.id, + amount, + }) + .finally(() => closeModal()) + } + /> + )} + {/* Transaction Action Dialog - for other actions like CANCEL */} orderTransactionAction @@ -398,15 +435,16 @@ export const OrderUnconfirmedDetails = ({ onClose={closeModal} onConfirm={() => orderVoid.mutate({ id })} /> - + onSubmit={amount => orderPaymentCapture.mutate({ - ...variables, + amount, id, }) } From e3343271ee5b92239f8b8084c77504b30c3543af Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 14:00:33 +0100 Subject: [PATCH 28/86] Add changeset --- .changeset/mighty-toes-march.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mighty-toes-march.md diff --git a/.changeset/mighty-toes-march.md b/.changeset/mighty-toes-march.md new file mode 100644 index 00000000000..4ddcd95b7c3 --- /dev/null +++ b/.changeset/mighty-toes-march.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Introduce new, unified order capture dialog From 17546f517fb04682e7747cbad42b22ae31af846f Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 14:01:50 +0100 Subject: [PATCH 29/86] Extract messages --- locale/defaultMessages.json | 112 ++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 0acb6a0fef5..72e426ddacc 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -430,6 +430,10 @@ "context": "column header", "string": "Title" }, + "0YOedO": { + "context": "label for already charged amount", + "string": "Captured so far" + }, "0YjGFG": { "context": "alert message", "string": "For subscription" @@ -449,6 +453,10 @@ "context": "ProductTypeDeleteWarningDialog multiple consent label", "string": "Yes, I want to delete those products types and assigned products" }, + "0f6YvV": { + "context": "dialog title", + "string": "Capture Payment" + }, "0iMYc+": { "context": "field is optional", "string": "(Optional)" @@ -1025,6 +1033,10 @@ "4YJHut": { "string": "Clear search" }, + "4YyeCx": { + "context": "label for order total amount", + "string": "Order total" + }, "4Z0O2B": { "context": "section header title", "string": "Gift Card Timeline" @@ -1593,6 +1605,10 @@ "context": "ordered product sku", "string": "SKU" }, + "8JEG80": { + "context": "warning when authorized is less than total", + "string": "The remaining authorization doesn't cover the balance. {shortfall} will need a separate payment." + }, "8LWaFr": { "context": "dialog content", "string": "{counter,plural,one{Are you sure you want to unpublish this model?} other{Are you sure you want to unpublish {displayQuantity} models?}}" @@ -2046,6 +2062,10 @@ "context": "no warehouses info", "string": "There are no warehouses set up for this product. To add stock quantity to the product configure a warehouse or use existing one by clicking button below." }, + "BJRu4V": { + "context": "pill status for partially captured outcome", + "string": "Partially captured" + }, "BJtUQI": { "context": "button", "string": "Add" @@ -2424,6 +2444,10 @@ "DWWw3M": { "string": "Model type Name" }, + "DXaxpH": { + "context": "outcome prediction when order will be overcharged", + "string": "This will result in an {status} order" + }, "DaPGcn": { "string": "Model title" }, @@ -2828,6 +2852,10 @@ "context": "tooltip", "string": "Checkout reservation time threshold is enabled in settings." }, + "G9y5Ze": { + "context": "pill status for fully captured outcome", + "string": "Fully captured" + }, "GA+Djy": { "string": "Are you sure you want to delete these voucher codes?" }, @@ -3014,6 +3042,10 @@ "context": "dialog content", "string": "You are not able to modify this group members. Solve this problem to continue with request." }, + "H0eCbU": { + "context": "label for transaction authorized amount", + "string": "Transaction authorized" + }, "H1L1cc": { "context": "url", "string": "URL" @@ -3144,6 +3176,10 @@ "HvJPcU": { "string": "Category deleted" }, + "HwIhau": { + "context": "status pill for fully authorized payment", + "string": "Fully Authorized" + }, "HwTMFL": { "string": "Go to channels" }, @@ -3241,6 +3277,10 @@ "ITYiRy": { "string": "Go to collections" }, + "IU1lif": { + "context": "radio option for custom capture amount", + "string": "Custom amount" + }, "IUeGzv": { "context": "plugin name", "string": "Plugin Name" @@ -3656,6 +3696,10 @@ "L87bp7": { "string": "Order payment successfully voided" }, + "L8J/jr": { + "context": "status pill when order is fully paid", + "string": "Fully Captured" + }, "L8seEc": { "string": "Subtotal" }, @@ -3874,10 +3918,18 @@ "context": "customer input label", "string": "Customer" }, + "MhlYkx": { + "context": "label for available authorization amount", + "string": "Available to capture (authorized)" + }, "MjUyhA": { "context": "section subheader", "string": "Active member since {date}" }, + "Mm/Stj": { + "context": "hint showing maximum allowed custom amount", + "string": "Max: {amount}" + }, "MmGlkp": { "context": "dialog header", "string": "Unassign Collections From Voucher" @@ -4166,6 +4218,10 @@ "context": "tab name", "string": "All staff members" }, + "OUMqG1": { + "context": "label for remaining balance to capture", + "string": "Remaining balance" + }, "OUX4LB": { "context": "input label", "string": "Model type" @@ -4589,6 +4645,10 @@ "context": "add new refund button", "string": "New refund" }, + "R/YHMH": { + "context": "label for amount already captured from this transaction", + "string": "Already captured" + }, "R4IIw1": { "context": "tracking number of the shipment", "string": "Tracking number" @@ -4844,6 +4904,10 @@ "context": "product filter label", "string": "Product" }, + "SnV3LR": { + "context": "error when no authorization exists", + "string": "No payment has been authorized for this order. The full amount of {amount} cannot be captured." + }, "SpngiS": { "context": "sale status", "string": "Status" @@ -5037,6 +5101,10 @@ "context": "order expiration card description", "string": "The time in days after expired orders will be deleted. Allowed range between 1 and 120." }, + "U0IK0G": { + "context": "label for authorized amount", + "string": "Authorized" + }, "U1eJIw": { "context": "order history message", "string": "Products were added to an order" @@ -5349,6 +5417,10 @@ "context": "command menu shortcut", "string": "Command menu" }, + "VwCTbx": { + "context": "outcome prediction when order will be fully charged", + "string": "This will result in a {status} order" + }, "VyC+Bm": { "context": "radio button label", "string": "Enter the refund amount without using the product list. Best for overcharges and custom adjustments." @@ -5690,6 +5762,10 @@ "context": "product pricing, section header", "string": "Pricing" }, + "XrliJg": { + "context": "label for amount selection", + "string": "Select amount to capture:" + }, "Xsh2Pa": { "context": "column picker search input placeholder", "string": "Search for columns" @@ -5972,6 +6048,10 @@ "context": "empty state message", "string": "No refunds made for this order." }, + "ZUYQ+C": { + "context": "status pill for partial authorization", + "string": "Partial authorisation" + }, "ZXOpCJ": { "string": "An unexpected issue occurred when parsing manifest. Please contact support. ({errorCode})" }, @@ -6285,6 +6365,10 @@ "context": "button", "string": "Create permission group" }, + "bRXgSC": { + "context": "capture button with amount", + "string": "Capture {amount}" + }, "bS7A8u": { "context": "add tracking button", "string": "Add tracking" @@ -7472,6 +7556,10 @@ "context": "product price", "string": "Select channel" }, + "jhyt3I": { + "context": "label for max capturable amount when partial authorization", + "string": "Remaining max (authorized)" + }, "jiXbx5": { "context": "fulfillment status canceled", "string": "Canceled" @@ -8013,6 +8101,10 @@ "mvVmbJ": { "string": "Install extension from manifest" }, + "mxGY7T": { + "context": "status pill for no authorization", + "string": "No Authorization" + }, "mxtAFx": { "string": "Are you sure you want to delete draft #{orderNumber}?" }, @@ -8634,6 +8726,10 @@ "context": "bulk delete label", "string": "Delete" }, + "qlfssi": { + "context": "label for remaining amount customer owes", + "string": "Balance due" + }, "qov29K": { "context": "dialog content", "string": "Select one of customer addresses or add a new address:" @@ -9008,6 +9104,10 @@ "tR+UuE": { "string": "User doesn't exist. Please check your email in URL" }, + "tS2K/N": { + "context": "radio option for capturing order total", + "string": "Order total" + }, "tTuCYj": { "context": "all gift cards label", "string": "All Gift Cards" @@ -9124,6 +9224,10 @@ "context": "shipping zones configuration", "string": "Change default weight unit" }, + "u7ShY+": { + "context": "pill status for overcaptured outcome", + "string": "Overcaptured" + }, "u9/vj9": { "context": "webhook input label", "string": "Target URL" @@ -9307,6 +9411,10 @@ "v3WWK+": { "string": "Status is invalid" }, + "v8e93p": { + "context": "hint for order total option", + "string": "Matches what customer owes" + }, "vAxm7u": { "string": "You must select a channel first and select at least one gift" }, @@ -9833,6 +9941,10 @@ "context": "warehouses section name", "string": "Warehouses" }, + "ycg2RR": { + "context": "outcome prediction when order will be partially charged", + "string": "This will result in a {status} order" + }, "ychKsb": { "context": "error message", "string": "Shipping method is required for this order" From 71ac249568793d8cd379af300b1aa4201d513b8b Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 14:17:20 +0100 Subject: [PATCH 30/86] Fix Callout tests to respect ThemeProvider --- .../AddCustomExtension.test.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx index f0818f0815e..7a2a0b79487 100644 --- a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx +++ b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx @@ -1,3 +1,4 @@ +import { ThemeProvider } from "@saleor/macaw-ui-next"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as React from "react"; @@ -8,6 +9,10 @@ import { usePermissions } from "./hooks/usePermissions"; import { useUserAppCreationPermissions } from "./hooks/useUserAppCreationPermissions"; import { useUserPermissionSet } from "./hooks/useUserPermissionMap"; +const Wrapper = ({ children }: React.PropsWithChildren<{}>) => ( + {children} +); + // Mock ResizeObserver used by Radix checkbox class ResizeObserverMock { observe() { @@ -61,7 +66,7 @@ describe("AddCustomExtension", () => { it("renders the component with all required elements", () => { // Arrange - render(); + render(, { wrapper: Wrapper }); // Assert expect(screen.getByPlaceholderText("Extension Name")).toBeInTheDocument(); @@ -73,7 +78,7 @@ describe("AddCustomExtension", () => { it("displays validation error when submitting empty form", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); // Act await userEvent.click(screen.getByText("save")); @@ -84,7 +89,7 @@ describe("AddCustomExtension", () => { it("creates app without permissions", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); @@ -107,7 +112,7 @@ describe("AddCustomExtension", () => { it("creates app with some permissions when checked by user", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); @@ -135,7 +140,7 @@ describe("AddCustomExtension", () => { it("creates app with all permissions when toggled 'Grant full access'", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -166,7 +171,7 @@ describe("AddCustomExtension", () => { it("creates app with no permissions when toggling between 'Grant full access'", async () => { // Arrange - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -211,7 +216,7 @@ describe("AddCustomExtension", () => { (useUserAppCreationPermissions as jest.Mock).mockReturnValue(true); // Act - render(); + render(, { wrapper: Wrapper }); // Assert expect(screen.getByText(/warning/i)).toBeInTheDocument(); @@ -225,7 +230,7 @@ describe("AddCustomExtension", () => { const availablePermissions = new Set(["MANAGE_ORDERS"]); (useUserPermissionSet as jest.Mock).mockReturnValue(availablePermissions); - render(); + render(, { wrapper: Wrapper }); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); From 86b7f7c499993bf7ab70f7dc7261ff5910fc1286 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 14:20:29 +0100 Subject: [PATCH 31/86] Add essential PriceField util tests --- src/components/PriceField/utils.test.ts | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/components/PriceField/utils.test.ts diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts new file mode 100644 index 00000000000..3fde43f2ffd --- /dev/null +++ b/src/components/PriceField/utils.test.ts @@ -0,0 +1,113 @@ +import { + getCurrencyDecimalPoints, + limitDecimalPlaces, + normalizeDecimalSeparator, + parseDecimalValue, +} from "./utils"; + +describe("normalizeDecimalSeparator", () => { + it("converts comma to dot", () => { + expect(normalizeDecimalSeparator("10,50")).toBe("10.50"); + }); + + it("leaves dot unchanged", () => { + expect(normalizeDecimalSeparator("10.50")).toBe("10.50"); + }); + + it("handles integers without separator", () => { + expect(normalizeDecimalSeparator("100")).toBe("100"); + }); + + it("handles empty string", () => { + expect(normalizeDecimalSeparator("")).toBe(""); + }); +}); + +describe("parseDecimalValue", () => { + it("parses dot-separated value", () => { + expect(parseDecimalValue("10.50")).toBe(10.5); + }); + + it("parses comma-separated value", () => { + expect(parseDecimalValue("10,50")).toBe(10.5); + }); + + it("parses integer", () => { + expect(parseDecimalValue("100")).toBe(100); + }); + + it("returns 0 for empty string", () => { + expect(parseDecimalValue("")).toBe(0); + }); + + it("returns 0 for invalid input", () => { + expect(parseDecimalValue("abc")).toBe(0); + }); + + it("handles negative values", () => { + expect(parseDecimalValue("-10.50")).toBe(-10.5); + }); +}); + +describe("limitDecimalPlaces", () => { + it("limits decimal places with dot separator", () => { + expect(limitDecimalPlaces("10.12345", 2)).toBe("10.12"); + }); + + it("limits decimal places with comma separator", () => { + expect(limitDecimalPlaces("10,12345", 2)).toBe("10,12"); + }); + + it("preserves original separator when limiting", () => { + expect(limitDecimalPlaces("10,999", 2)).toBe("10,99"); + expect(limitDecimalPlaces("10.999", 2)).toBe("10.99"); + }); + + it("returns integer when maxDecimalPlaces is 0", () => { + expect(limitDecimalPlaces("10.50", 0)).toBe("10"); + expect(limitDecimalPlaces("10,50", 0)).toBe("10"); + }); + + it("returns value unchanged if decimal places are within limit", () => { + expect(limitDecimalPlaces("10.12", 2)).toBe("10.12"); + expect(limitDecimalPlaces("10.1", 2)).toBe("10.1"); + }); + + it("returns value unchanged if no decimal part", () => { + expect(limitDecimalPlaces("100", 2)).toBe("100"); + }); + + it("handles three decimal places for currencies like KWD", () => { + expect(limitDecimalPlaces("10.1234", 3)).toBe("10.123"); + }); + + it("handles zero decimal places for currencies like JPY", () => { + expect(limitDecimalPlaces("1000.99", 0)).toBe("1000"); + }); +}); + +describe("getCurrencyDecimalPoints", () => { + it("returns 2 for USD", () => { + expect(getCurrencyDecimalPoints("USD")).toBe(2); + }); + + it("returns 2 for EUR", () => { + expect(getCurrencyDecimalPoints("EUR")).toBe(2); + }); + + it("returns 0 for JPY (Japanese Yen)", () => { + expect(getCurrencyDecimalPoints("JPY")).toBe(0); + }); + + it("returns 3 for KWD (Kuwaiti Dinar)", () => { + expect(getCurrencyDecimalPoints("KWD")).toBe(3); + }); + + it("returns 2 as fallback for undefined currency", () => { + expect(getCurrencyDecimalPoints(undefined)).toBe(2); + }); + + it("returns 2 as fallback for invalid currency code", () => { + expect(getCurrencyDecimalPoints("INVALID")).toBe(2); + }); +}); From 6bfcc7c5cb263c2dc9bf7c9373d2cad5456b33a8 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 14:33:26 +0100 Subject: [PATCH 32/86] Add dialog tests --- .../OrderCaptureDialog.test.tsx | 474 ++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx new file mode 100644 index 00000000000..171c5b61da6 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -0,0 +1,474 @@ +import { ConfirmButtonTransitionState } from "@dashboard/components/ConfirmButton"; +import { OrderErrorCode, OrderErrorFragment } from "@dashboard/graphql"; +import { IMoney } from "@dashboard/utils/intl"; +import Wrapper from "@test/wrapper"; +import { fireEvent, render, screen, within } from "@testing-library/react"; + +import { OrderCaptureDialog, OrderCaptureDialogProps } from "./OrderCaptureDialog"; + +const createMoney = (amount: number, currency = "USD"): IMoney => ({ + amount, + currency, +}); + +const defaultProps: OrderCaptureDialogProps = { + open: true, + confirmButtonState: "default" as ConfirmButtonTransitionState, + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onClose: jest.fn(), + onSubmit: jest.fn(), +}; + +const renderDialog = (props: Partial = {}) => + render( + + + , + ); + +describe("OrderCaptureDialog", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("rendering", () => { + it("renders the dialog when open is true", () => { + // Arrange & Act + renderDialog(); + + // Assert + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Capture Payment")).toBeInTheDocument(); + }); + + it("does not render the dialog when open is false", () => { + // Arrange & Act + renderDialog({ open: false }); + + // Assert + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("displays order total label", () => { + // Arrange & Act + renderDialog({ orderTotal: createMoney(150) }); + + // Assert + expect(screen.getByText("Order total")).toBeInTheDocument(); + }); + + it("displays available to capture label", () => { + // Arrange & Act + renderDialog({ authorizedAmount: createMoney(80) }); + + // Assert + expect(screen.getByText("Available to capture (authorized)")).toBeInTheDocument(); + }); + }); + + describe("authorization status", () => { + it("shows 'Fully Authorized' pill when authorized >= remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Authorized")).toBeInTheDocument(); + }); + + it("shows 'Partial authorisation' pill when authorized < remaining", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Partial authorisation")).toBeInTheDocument(); + }); + + it("shows warning callout for partial authorization with shortfall", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect( + screen.getByText(/The remaining authorization doesn't cover the balance/), + ).toBeInTheDocument(); + }); + + it("shows 'No Authorization' pill when authorized is 0", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText("No Authorization")).toBeInTheDocument(); + }); + + it("shows error callout when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + expect(screen.getByText(/No payment has been authorized for this order/)).toBeInTheDocument(); + }); + + it("shows 'Fully Captured' pill when order is fully paid", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + chargedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByText("Fully Captured")).toBeInTheDocument(); + }); + }); + + describe("radio options", () => { + it("defaults to first option for full authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const selectedRadio = within(radioGroup).getByRole("radio", { checked: true }); + + expect(selectedRadio).toHaveAttribute("value", "orderTotal"); + }); + + it("shows 'Remaining max (authorized)' label for partial authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert + expect(screen.getByText("Remaining max (authorized)")).toBeInTheDocument(); + }); + + it("disables radio options when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const radioGroup = screen.getByRole("radiogroup"); + const radios = within(radioGroup).getAllByRole("radio"); + + radios.forEach(radio => { + expect(radio).toBeDisabled(); + }); + }); + + it("allows selecting custom amount option", async () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + expect(customRadio).toBeChecked(); + }); + }); + + describe("custom amount input", () => { + const getCustomInput = () => { + const dialog = screen.getByRole("dialog"); + + return dialog.querySelector('input[type="text"]') as HTMLInputElement; + }; + + it("is disabled when custom option is not selected", () => { + // Arrange & Act + renderDialog(); + + // Assert + const input = getCustomInput(); + + expect(input).toBeDisabled(); + }); + + it("is enabled when custom option is selected", () => { + // Arrange + renderDialog(); + + // Act + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Assert + const input = getCustomInput(); + + expect(input).not.toBeDisabled(); + }); + + it("shows max capturable hint", () => { + // Arrange & Act + renderDialog({ + authorizedAmount: createMoney(75), + }); + + // Assert + expect(screen.getByText(/Max:/)).toBeInTheDocument(); + }); + + it("accepts valid custom amount input", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50" } }); + + // Assert + expect(input).toHaveValue("50"); + }); + + it("limits decimal places based on currency", () => { + // Arrange + renderDialog(); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + // Act + const input = getCustomInput(); + + fireEvent.change(input, { target: { value: "50.999" } }); + + // Assert - USD has 2 decimal places + expect(input).toHaveValue("50.99"); + }); + }); + + describe("submit button", () => { + it("shows capture amount in button text", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert + expect(screen.getByRole("button", { name: /Capture/i })).toBeInTheDocument(); + }); + + it("calls onSubmit with correct amount for full authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(100); + }); + + it("calls onSubmit with max available for partial authorization", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + onSubmit, + }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(50); + }); + + it("calls onSubmit with custom amount when selected", () => { + // Arrange + const onSubmit = jest.fn(); + + renderDialog({ onSubmit }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "25" } }); + + // Act + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + fireEvent.click(captureButton); + + // Assert + expect(onSubmit).toHaveBeenCalledWith(25); + }); + + it("is disabled when no authorization", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + + it("is disabled when custom amount exceeds max", () => { + // Arrange + renderDialog({ + authorizedAmount: createMoney(50), + }); + + const customRadio = screen.getByRole("radio", { name: /Custom amount/i }); + + fireEvent.click(customRadio); + + const dialog = screen.getByRole("dialog"); + const input = dialog.querySelector('input[type="text"]') as HTMLInputElement; + + fireEvent.change(input, { target: { value: "100" } }); + + // Assert + const captureButton = screen.getByRole("button", { name: /Capture/i }); + + expect(captureButton).toBeDisabled(); + }); + }); + + describe("close button", () => { + it("calls onClose when back button is clicked", () => { + // Arrange + const onClose = jest.fn(); + + renderDialog({ onClose }); + + // Act + const backButton = screen.getByRole("button", { name: /back/i }); + + fireEvent.click(backButton); + + // Assert + expect(onClose).toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("displays error messages when provided", () => { + // Arrange + const errors = [ + { + __typename: "OrderError" as const, + code: OrderErrorCode.CAPTURE_INACTIVE_PAYMENT, + field: null, + addressType: null, + message: null, + orderLines: null, + }, + ] as OrderErrorFragment[]; + + // Act + renderDialog({ errors }); + + // Assert + // The error message will be rendered by getOrderErrorMessage utility + const dialog = screen.getByRole("dialog"); + + expect(dialog).toBeInTheDocument(); + }); + }); + + describe("outcome prediction", () => { + it("shows outcome message when capturing full balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in a/)).toBeInTheDocument(); + }); + + it("shows outcome message when capturing partial balance", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + }); + + // Assert - outcome prediction message is shown + expect(screen.getByText(/This will result in a/)).toBeInTheDocument(); + }); + }); + + describe("with charged amount", () => { + it("displays 'Captured so far' label when there is prior charged amount", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Captured so far")).toBeInTheDocument(); + }); + + it("displays 'Balance due' label with prior charges", () => { + // Arrange & Act + renderDialog({ + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(30), + }); + + // Assert + expect(screen.getByText("Balance due")).toBeInTheDocument(); + }); + }); +}); From e2a82e43cb8f98482efa96c729d71f926b39b00c Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 12 Dec 2025 15:08:34 +0100 Subject: [PATCH 33/86] Fix types --- src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index b9523ac2571..bde64b3b46f 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -379,7 +379,7 @@ export const OrderCaptureDialog = ({ setSelectedOption(value)} + onValueChange={value => setSelectedOption(value as CaptureAmountOption)} > {/* Order Total / Remaining Balance / Remaining Max option */} From 655723f5a35db9c0479fe11b58a77430a3e81c25 Mon Sep 17 00:00:00 2001 From: Mikail Kocak Date: Fri, 12 Dec 2025 17:47:19 +0100 Subject: [PATCH 34/86] fix: add support for feature branches in testenvs (CI/CD) This fixes an issue where deploying to testenvs was crashing when a developer was using feature branches (e.g., `head=my-branch, base=my-feature` instead of `head=my-branch, base=main`) We believe this is the simplest way of handling this case. We do not believe we need to build any complicated solutions for this issue as we do not expect anyone to be using feature branches against stables branches (e.g., 3.21) --- .../actions/prepare-api-variables/action.yml | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/actions/prepare-api-variables/action.yml b/.github/actions/prepare-api-variables/action.yml index b9da6904800..8924cf41294 100644 --- a/.github/actions/prepare-api-variables/action.yml +++ b/.github/actions/prepare-api-variables/action.yml @@ -55,16 +55,25 @@ runs: echo "POOL_NAME=${PREFIX}${PULL_REQUEST_NUMBER}" >> $GITHUB_OUTPUT echo "POOL_INSTANCE=https://${PREFIX}${PULL_REQUEST_NUMBER}.staging.saleor.cloud" >> $GITHUB_OUTPUT - if [[ "$DESTINATION_BRANCH" == 'main' ]]; then - echo "BACKUP_NAMESPACE=snapshot-automation-tests" >> $GITHUB_OUTPUT - echo "SALEOR_CLOUD_SERVICE=saleor-master-staging" >> $GITHUB_OUTPUT - echo "RUN_SLUG=${PREFIX}${PULL_REQUEST_NUMBER}" >> $GITHUB_OUTPUT - else - # it handles pull requests to the other branches than main, e.g. release branches + if [[ "$DESTINATION_BRANCH" =~ ^[0-9]+\.[0-9]+$ ]]; then + # handles pull requests to the other branches than main, e.g. release branches VERSION_SLUG=$(echo "${DESTINATION_BRANCH}" | sed "s/\.//") echo "BACKUP_NAMESPACE=snapshot-automation-tests-${DESTINATION_BRANCH}" >> $GITHUB_OUTPUT echo "SALEOR_CLOUD_SERVICE=saleor-staging-v${VERSION_SLUG}" >> $GITHUB_OUTPUT echo "RUN_SLUG=${DESTINATION_BRANCH}" >> $GITHUB_OUTPUT + else + # fallback to "saleor-master-staging" (i.e., 'main' branch) when the base + # branch isn't a version number (e.g., 3.21). + # We expect this to occur in two cases: + # 1. The base branch is `main` + # 2. The base branch is a feature branch, e.g., "unify-order-value-sections", + # in which case it makes sense to fallback and to treat the PR as targeting + # the `main` branch as `main` is an unstable (dev) branch, whereas + # version number branches are stable branches thus we do not expect anyone + # to be using feature branches against these. + echo "BACKUP_NAMESPACE=snapshot-automation-tests" >> $GITHUB_OUTPUT + echo "SALEOR_CLOUD_SERVICE=saleor-master-staging" >> $GITHUB_OUTPUT + echo "RUN_SLUG=${PREFIX}${PULL_REQUEST_NUMBER}" >> $GITHUB_OUTPUT fi exit 0 From 810a191cff279f51b96bfc1b6d882149ab777de2 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sat, 13 Dec 2025 13:31:13 +0100 Subject: [PATCH 35/86] Extract messages --- locale/defaultMessages.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 3d1056692c8..4304fe9025a 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3283,6 +3283,7 @@ "IU1lif": { "context": "radio option for custom capture amount", "string": "Custom amount" + }, "IUWJKt": { "context": "order was discounted event title", "string": "Order was discounted" @@ -5137,10 +5138,6 @@ "context": "label for authorized amount", "string": "Authorized" }, - "U1eJIw": { - "context": "order history message", - "string": "Products were added to an order" - }, "U2DyeR": { "string": "Are you sure you want to delete structure {menuName}?" }, @@ -5452,6 +5449,7 @@ "VwCTbx": { "context": "outcome prediction when order will be fully charged", "string": "This will result in a {status} order" + }, "VxStyU": { "context": "order history message", "string": "Replacement order was created" From 7e640948f06a7351a582b6334a50407f059212b5 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sat, 13 Dec 2025 14:25:32 +0100 Subject: [PATCH 36/86] Don't ignore apollo errors --- src/misc.ts | 11 ++++++++++- src/orders/utils/OrderDetailsViewModel.ts | 6 ++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/misc.ts b/src/misc.ts index cff05c1bfcf..7f9c88d8789 100644 --- a/src/misc.ts +++ b/src/misc.ts @@ -293,7 +293,16 @@ export function getMutationStatus( diff --git a/src/orders/utils/OrderDetailsViewModel.ts b/src/orders/utils/OrderDetailsViewModel.ts index 7179ab19728..66d49292198 100644 --- a/src/orders/utils/OrderDetailsViewModel.ts +++ b/src/orders/utils/OrderDetailsViewModel.ts @@ -46,8 +46,10 @@ export abstract class OrderDetailsViewModel { return orderActions.includes(OrderAction.MARK_AS_PAID); } - static canOrderCapture(actions: OrderAction[]): boolean { - return actions.includes(OrderAction.CAPTURE); + static canOrderCapture(_actions: OrderAction[]): boolean { + // TODO: Remove this override - temporary for testing capture dialog + return true; + // return actions.includes(OrderAction.CAPTURE); } static canOrderVoid(actions: OrderAction[]): boolean { From 818bb5e0b165ccfdd35b8ad0f92890e99a50e950 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sat, 13 Dec 2025 14:30:31 +0100 Subject: [PATCH 37/86] Remove the temp testing hack --- src/orders/utils/OrderDetailsViewModel.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/orders/utils/OrderDetailsViewModel.ts b/src/orders/utils/OrderDetailsViewModel.ts index 66d49292198..7179ab19728 100644 --- a/src/orders/utils/OrderDetailsViewModel.ts +++ b/src/orders/utils/OrderDetailsViewModel.ts @@ -46,10 +46,8 @@ export abstract class OrderDetailsViewModel { return orderActions.includes(OrderAction.MARK_AS_PAID); } - static canOrderCapture(_actions: OrderAction[]): boolean { - // TODO: Remove this override - temporary for testing capture dialog - return true; - // return actions.includes(OrderAction.CAPTURE); + static canOrderCapture(actions: OrderAction[]): boolean { + return actions.includes(OrderAction.CAPTURE); } static canOrderVoid(actions: OrderAction[]): boolean { From 0ed6529e351469a3d5e588b436ec481736a353df Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sat, 13 Dec 2025 14:59:52 +0100 Subject: [PATCH 38/86] Reset the dialog to the default option on open --- .../OrderCaptureDialog/OrderCaptureDialog.tsx | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index bde64b3b46f..01cf228a148 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -16,7 +16,7 @@ import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transac import { IMoney } from "@dashboard/utils/intl"; import { Box, Input, RadioGroup, Text } from "@saleor/macaw-ui-next"; import { AlertTriangle, Box as BoxIcon, CheckCircle2, CircleAlert, CreditCard } from "lucide-react"; -import { ChangeEvent, useMemo, useState } from "react"; +import { ChangeEvent, useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { messages } from "./messages"; @@ -112,15 +112,11 @@ export const OrderCaptureDialog = ({ const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; const shortfall = remainingToPay - availableToCapture; - // Default selection based on status + // Default selection: always prefer "orderTotal" unless it's disabled + const isFirstOptionDisabled = authStatus === "none" || authStatus === "charged"; const getDefaultOption = (): CaptureAmountOption => { - // For full and partial, default to the first option - // For "none" or "charged" we can't select anything meaningful - if (authStatus === "full" || authStatus === "partial") { - return "orderTotal"; - } - - return "custom"; + // Always default to orderTotal (first option) unless it's disabled + return isFirstOptionDisabled ? "custom" : "orderTotal"; }; const getDefaultCustomAmount = (): string => { @@ -140,6 +136,15 @@ export const OrderCaptureDialog = ({ const [selectedOption, setSelectedOption] = useState(getDefaultOption); const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + // Reset state when dialog opens to ensure correct defaults based on current props + useEffect(() => { + if (open) { + setSelectedOption(getDefaultOption()); + setCustomAmount(getDefaultCustomAmount()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + // Get max decimal places for this currency (e.g., 2 for USD, 0 for JPY, 3 for KWD) const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); @@ -393,15 +398,9 @@ export const OrderCaptureDialog = ({ - + {formatMoney(authStatus === "partial" ? availableToCapture : remainingToPay)} From ec4c2bc81b2b53f01890fcf078e76aa86decfa5e Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sun, 14 Dec 2025 14:19:28 +0100 Subject: [PATCH 39/86] Tests for empty state --- .../OrderSummary/OrderSummary.test.tsx | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/orders/components/OrderSummary/OrderSummary.test.tsx b/src/orders/components/OrderSummary/OrderSummary.test.tsx index 8943c3044b8..746aaba24ba 100644 --- a/src/orders/components/OrderSummary/OrderSummary.test.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.test.tsx @@ -88,6 +88,66 @@ describe("OrderSummary", () => { }); }); + describe("PaymentsSummaryEmptyState", () => { + it("should display empty state with CreditCard icon when hasNoPayment is true", () => { + // Arrange + const mockOrder = createOrderWithNoPayment(); + const onMarkAsPaid = jest.fn(); + + // Act + render( + + + , + ); + + // Assert + expect(screen.getByText("No payment received")).toBeInTheDocument(); + }); + + it("should display instruction message in empty state", () => { + // Arrange + const mockOrder = createOrderWithNoPayment(); + const onMarkAsPaid = jest.fn(); + + // Act + render( + + + , + ); + + // Assert + expect( + screen.getByText("Mark as paid manually if the payment is confirmed"), + ).toBeInTheDocument(); + }); + + it("should not display empty state when order has transactions", () => { + // Arrange + const mockOrder = { + ...orderFixture("test-id"), + transactions: [transaction], + payments: [], + grantedRefunds: [], + }; + const onMarkAsPaid = jest.fn(); + + // Act + render( + + + , + ); + + // Assert + expect(screen.queryByText("No payment received")).not.toBeInTheDocument(); + expect( + screen.queryByText("Mark as paid manually if the payment is confirmed"), + ).not.toBeInTheDocument(); + }); + }); + describe("Transactions API Mode", () => { it("should show TransactionsApiButtons when useLegacyPaymentsApi is false", () => { // Arrange From 3ee9d9b253f2f8d39fbd7de143eca3607ca9a5fb Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sun, 14 Dec 2025 14:23:44 +0100 Subject: [PATCH 40/86] Drop no-op function in favour of optional callback --- .../OrderDraftPage/OrderDraftPage.tsx | 3 --- .../OrderSummary/LegacyPaymentsApiButtons.tsx | 7 +++--- .../components/OrderSummary/OrderSummary.tsx | 2 +- .../OrderSummary/TransactionsApiButtons.tsx | 23 ++++++++++--------- 4 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx index 36e2f0004ab..7670ca6e9f3 100644 --- a/src/orders/components/OrderDraftPage/OrderDraftPage.tsx +++ b/src/orders/components/OrderDraftPage/OrderDraftPage.tsx @@ -157,9 +157,6 @@ const OrderDraftPage = (props: OrderDraftPageProps) => { <> { - // Draft orders cannot be marked as paid - }} isEditable onShippingMethodEdit={onShippingMethodEdit} errors={errors} diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index 0a974c3b9ca..54982ef689c 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -8,7 +8,7 @@ type Props = { canRefund: boolean; canVoid: boolean; canMarkAsPaid: boolean; - onMarkAsPaid: () => any; + onMarkAsPaid?: () => void; onLegacyPaymentsApiCapture?: () => any; onLegacyPaymentsApiRefund?: () => any; onLegacyPaymentsApiVoid?: () => any; @@ -28,7 +28,8 @@ export const LegacyPaymentsApiButtons = ({ const intl = useIntl(); const showButtons = - order?.status !== OrderStatus.CANCELED && (canCapture || canRefund || canVoid || canMarkAsPaid); + order?.status !== OrderStatus.CANCELED && + (canCapture || canRefund || canVoid || (canMarkAsPaid && onMarkAsPaid)); if (!showButtons) { return null; @@ -64,7 +65,7 @@ export const LegacyPaymentsApiButtons = ({ })} )} - {canMarkAsPaid && ( + {canMarkAsPaid && onMarkAsPaid && ( - ) + ); }; From 301ace82c72ff696e210df5a6f1396d9f32909c3 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Sun, 14 Dec 2025 14:29:48 +0100 Subject: [PATCH 41/86] Update variable name --- .../OrderShippingMethodEditDialog.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx index af6cb7506a8..67f88a9f6f1 100644 --- a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx @@ -187,8 +187,10 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps data-test-id="shipping-method-select" value={data.shippingMethod} onChange={({ target }) => { - const newValue = target.value; - const isDisabled = choices.find(choice => choice.value === newValue)?.disabled; + const targetValue = target.value; + const isDisabled = choices.find( + choice => choice.value === targetValue, + )?.disabled; if (isDisabled) { return; @@ -198,7 +200,9 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps target: { name: "shippingMethod", value: - typeof newValue === "string" ? newValue : (newValue as Option)?.value, + typeof targetValue === "string" + ? targetValue + : (targetValue as Option)?.value, }, }); }} From 6178530429ee29da790122d8bb2a9eca82ec5063 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 09:05:59 -0100 Subject: [PATCH 42/86] Emphasise the totals in the Summary section --- .../OrderSummary/OrderSummaryListAmount.tsx | 2 +- .../OrderSummary/OrderSummaryListItem.tsx | 15 ++++++++++++--- src/orders/components/OrderSummary/OrderValue.tsx | 2 +- .../components/OrderSummary/PaymentsSummary.tsx | 1 + 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx index 64a754526ea..6e0c8b505c7 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListAmount.tsx @@ -15,7 +15,7 @@ export const OrderSummaryListAmount = ({ amount, showSign = false, ...props }: P const intl = useIntl(); return ( - + {intl.formatNumber(amount, { minimumFractionDigits: 2, signDisplay: showSign ? "exceptZero" : "auto", diff --git a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx index 4c84564f3c5..99433d3c3fb 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx @@ -11,6 +11,7 @@ type Props = PropsWithBox<{ currency?: string; title?: string; amountTitle?: string; + bold?: boolean; }>; export const OrderSummaryListItem = ({ @@ -20,18 +21,26 @@ export const OrderSummaryListItem = ({ currency, title, amountTitle, + bold = false, ...props }: Props): ReactNode => { + const fontWeight = bold ? "bold" : "regular"; + return ( - + {children} - + {currency} {" "} - + ); diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index e04514c1d9a..10389babdf2 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -473,7 +473,7 @@ export const OrderValue = (props: Props): ReactNode => { }, { currency: ( - + {orderTotal.gross.currency} ), diff --git a/src/orders/components/OrderSummary/PaymentsSummary.tsx b/src/orders/components/OrderSummary/PaymentsSummary.tsx index 1274d24df31..5f310f798a4 100644 --- a/src/orders/components/OrderSummary/PaymentsSummary.tsx +++ b/src/orders/components/OrderSummary/PaymentsSummary.tsx @@ -79,6 +79,7 @@ export const PaymentsSummary = ({ orderAmounts, order, hasNoPayment, ...props }: showSign showCurrency currency={orderAmounts.totalBalance.currency} + bold > {intl.formatMessage({ defaultMessage: "Outstanding balance", From 9eda3bf386f79a32c901b8e69f45c1e86e849114 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 21:13:21 -0100 Subject: [PATCH 43/86] Remove the unnecessary spread of props --- .../components/OrderSummary/OrderSummary.tsx | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummary.tsx b/src/orders/components/OrderSummary/OrderSummary.tsx index 85b4f0b2afd..9c7f86714fb 100644 --- a/src/orders/components/OrderSummary/OrderSummary.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.tsx @@ -31,13 +31,7 @@ type Props = PropsWithBox< >; export const OrderSummary = (props: Props) => { - const { - order, - onMarkAsPaid, - useLegacyPaymentsApi = false, - isEditable = false, - ...restProps - } = props; + const { order, onMarkAsPaid, useLegacyPaymentsApi = false, isEditable = false } = props; const intl = useIntl(); const giftCardsAmount = OrderDetailsViewModel.getGiftCardsAmountUsed({ id: order.id, @@ -57,32 +51,10 @@ export const OrderSummary = (props: Props) => { const canVoid = OrderDetailsViewModel.canOrderVoid(order.actions); const canRefund = OrderDetailsViewModel.canOrderRefund(order.actions); - // Extract editable props const editableProps = isEditable ? (props as Props & EditableOrderSummary) : null; - // Filter out props that shouldn't be passed to the DOM - const { - isEditable: _isEditable, - onShippingMethodEdit: _onShippingMethodEdit, - errors: _errors, - orderDiscount: _orderDiscount, - addOrderDiscount: _addOrderDiscount, - removeOrderDiscount: _removeOrderDiscount, - openDialog: _openDialog, - closeDialog: _closeDialog, - isDialogOpen: _isDialogOpen, - orderDiscountAddStatus: _orderDiscountAddStatus, - orderDiscountRemoveStatus: _orderDiscountRemoveStatus, - undiscountedPrice: _undiscountedPrice, - discountedPrice: _discountedPrice, - onLegacyPaymentsApiCapture: _onLegacyPaymentsApiCapture, - onLegacyPaymentsApiRefund: _onLegacyPaymentsApiRefund, - onLegacyPaymentsApiVoid: _onLegacyPaymentsApiVoid, - ...boxProps - } = restProps as any; - return ( - + {intl.formatMessage({ From 482099b75f4e6ebb6c706579318e9cf9eafa7217 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 21:40:29 -0100 Subject: [PATCH 44/86] Move discounts outside of the total lines as the dsicounts apply directly to subtotal --- .../components/OrderSummary/OrderValue.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 10389babdf2..6b83bd4cdc1 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -130,6 +130,11 @@ const messages = defineMessages({ defaultMessage: "Gift card amount used", description: "tooltip for gift card amount", }, + fixedAmount: { + id: "YPCB7b", + defaultMessage: "Fixed amount", + description: "label for fixed amount discount type", + }, }); type BaseProps = { @@ -300,13 +305,15 @@ export const OrderValue = (props: Props): ReactNode => { return discounts.map(discount => ( {intl.formatMessage(messages.discount)}{" "} {discount.name} + {" "} + + (applied) )); @@ -358,15 +365,12 @@ export const OrderValue = (props: Props): ReactNode => { ); } - const discountDisplayValue = discountLabel.percentage || discountLabel.value; + const discountDisplayValue = + discountLabel.percentage || intl.formatMessage(messages.fixedAmount); const discountAmount = parseFloat(discountLabel.value) || 0; return ( - 0} - > + {intl.formatMessage(messages.discount)}{" "} { @@ -399,7 +403,10 @@ export const OrderValue = (props: Props): ReactNode => { )} - + {" "} + + (applied) + ); }; @@ -456,8 +463,6 @@ export const OrderValue = (props: Props): ReactNode => { )} - {renderDiscountRow()} - { + {renderDiscountRow()} + {displayGrossPrices && ( Date: Mon, 15 Dec 2025 21:45:13 -0100 Subject: [PATCH 45/86] Fix returned type --- src/orders/components/OrderSummary/OrderSummary.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummary.tsx b/src/orders/components/OrderSummary/OrderSummary.tsx index 9c7f86714fb..bbcc665a9f8 100644 --- a/src/orders/components/OrderSummary/OrderSummary.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.tsx @@ -24,9 +24,9 @@ type Props = PropsWithBox< order: OrderDetailsFragment; onMarkAsPaid?: () => void; useLegacyPaymentsApi?: boolean; - onLegacyPaymentsApiCapture?: () => any; - onLegacyPaymentsApiRefund?: () => any; - onLegacyPaymentsApiVoid?: () => any; + onLegacyPaymentsApiCapture?: () => void; + onLegacyPaymentsApiRefund?: () => void; + onLegacyPaymentsApiVoid?: () => void; } & (EditableOrderSummary | ReadOnlyOrderSummary) >; From c1a5e6f0fe949246ae58b53fa8876b3faa9e9fe5 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 22:14:10 -0100 Subject: [PATCH 46/86] Improve ButtonLink and drop InlineLink --- src/components/ButtonLink/ButtonLink.tsx | 9 +++- .../OrderSummary/OrderSummaryListItem.tsx | 10 +++- .../components/OrderSummary/OrderValue.tsx | 48 ++++--------------- 3 files changed, 26 insertions(+), 41 deletions(-) diff --git a/src/components/ButtonLink/ButtonLink.tsx b/src/components/ButtonLink/ButtonLink.tsx index 4b8c8d784ce..d62c22b32f4 100644 --- a/src/components/ButtonLink/ButtonLink.tsx +++ b/src/components/ButtonLink/ButtonLink.tsx @@ -36,11 +36,18 @@ export const ButtonLink = ({ }} cursor={disabled ? "not-allowed" : "pointer"} style={{ + display: "inline", textUnderlineOffset: vars.spacing[1], padding: 0, + margin: 0, + height: "auto", + minHeight: 0, color, - fontWeight: 400, + fontWeight: "inherit", + fontSize: "inherit", + lineHeight: "inherit", textDecorationColor: color, + verticalAlign: "baseline", }} {...props} > diff --git a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx index 99433d3c3fb..ed54de7c745 100644 --- a/src/orders/components/OrderSummary/OrderSummaryListItem.tsx +++ b/src/orders/components/OrderSummary/OrderSummaryListItem.tsx @@ -27,7 +27,15 @@ export const OrderSummaryListItem = ({ const fontWeight = bold ? "bold" : "regular"; return ( - + {children} diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index 6b83bd4cdc1..ab44d30fe3c 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -1,3 +1,4 @@ +import { ButtonLink } from "@dashboard/components/ButtonLink"; import { DiscountValueTypeEnum, OrderDetailsFragment, @@ -23,37 +24,6 @@ const emptyDiscount: OrderDiscountCommonInput = { calculationMode: DiscountValueTypeEnum.PERCENTAGE, }; -const InlineLink = ({ - children, - onClick, - title, - "data-test-id": dataTestId, -}: { - children: ReactNode; - onClick?: () => void; - title?: string; - "data-test-id"?: string; -}): ReactNode => ( - { - e.preventDefault(); - onClick?.(); - }} - href="#" - title={title} - data-test-id={dataTestId} - style={{ - cursor: "pointer", - textDecoration: "none", - }} - __textDecoration={{ hover: "underline" }} - > - {children} - -); - const messages = defineMessages({ discount: { id: "+8v1ny", @@ -257,9 +227,9 @@ export const OrderValue = (props: Props): ReactNode => { return ( {intl.formatMessage(messages.shipping)}{" "} - + {shippingMethodName} - + ); } @@ -288,12 +258,12 @@ export const OrderValue = (props: Props): ReactNode => { return ( - {intl.formatMessage(messages.setShippingMethod)} - + ); }; @@ -339,9 +309,9 @@ export const OrderValue = (props: Props): ReactNode => { > - + {intl.formatMessage(messages.addDiscount)} - + @@ -382,9 +352,9 @@ export const OrderValue = (props: Props): ReactNode => { > - + {discountDisplayValue} - + From c4fe0644238e4b7e1a734c3bcbb69c2c264a315f Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 22:19:33 -0100 Subject: [PATCH 47/86] Simplify conditional rendering of shipping messages --- .../components/OrderSummary/OrderValue.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.tsx b/src/orders/components/OrderSummary/OrderValue.tsx index ab44d30fe3c..144e02d1646 100644 --- a/src/orders/components/OrderSummary/OrderValue.tsx +++ b/src/orders/components/OrderSummary/OrderValue.tsx @@ -236,21 +236,13 @@ export const OrderValue = (props: Props): ReactNode => { const hasShippingAddress = !!editableProps?.shippingAddress; - if (!hasShippingAddress) { + if (!hasShippingAddress || !hasShippingMethods) { return ( - {intl.formatMessage(messages.noShippingAddress)} - - - ); - } - - if (!hasShippingMethods) { - return ( - - - {intl.formatMessage(messages.noShippingMethods)} + {intl.formatMessage( + !hasShippingAddress ? messages.noShippingAddress : messages.noShippingMethods, + )} ); From feb3c07a6c60045e9613c4bf3d6ef49fa694a1df Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 22:23:27 -0100 Subject: [PATCH 48/86] Extract messages --- locale/defaultMessages.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index e64a85f5644..d51fd24931f 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -5825,6 +5825,10 @@ "YIT1XP": { "string": "Create voucher" }, + "YPCB7b": { + "context": "label for fixed amount discount type", + "string": "Fixed amount" + }, "YQ3EXR": { "context": "product types section name", "string": "Product Types" From fbe015afc8d56ed708a53a193eeb1f3ae1538148 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 22:34:38 -0100 Subject: [PATCH 49/86] When isClearable is true, selecting "No shipping method" is intentional, so the button stays enabled --- locale/defaultMessages.json | 8 ++++---- .../OrderShippingMethodEditDialog.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 8fea85a08d2..1ea3100297a 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -5849,14 +5849,14 @@ "YIT1XP": { "string": "Create voucher" }, - "YPCB7b": { - "context": "label for fixed amount discount type", - "string": "Fixed amount" - }, "YL8K/3": { "context": "automatic completion delay input label", "string": "Delay before completion (minutes). Default is 30." }, + "YPCB7b": { + "context": "label for fixed amount discount type", + "string": "Fixed amount" + }, "YQ3EXR": { "context": "product types section name", "string": "Product Types" diff --git a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx index 67f88a9f6f1..174b6791093 100644 --- a/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx +++ b/src/orders/components/OrderShippingMethodEditDialog/OrderShippingMethodEditDialog.tsx @@ -224,6 +224,7 @@ const OrderShippingMethodEditDialog = (props: OrderShippingMethodEditDialogProps data-test-id="confirm-button" transitionState={confirmButtonState} onClick={submit} + disabled={!isClearable && data.shippingMethod === NO_SHIPPING_METHOD_ID} > From 4fac6c6926b1624c5571a1764f68da20f07fb45c Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 15 Dec 2025 22:44:42 -0100 Subject: [PATCH 50/86] Fix tests --- src/orders/components/OrderSummary/OrderValue.test.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderValue.test.tsx b/src/orders/components/OrderSummary/OrderValue.test.tsx index 4840d0045ce..5ade8808c0b 100644 --- a/src/orders/components/OrderSummary/OrderValue.test.tsx +++ b/src/orders/components/OrderSummary/OrderValue.test.tsx @@ -216,7 +216,7 @@ describe("OrderValue", () => { const setShippingLink = screen.getByText("Set shipping method"); expect(setShippingLink).toBeInTheDocument(); - expect(setShippingLink.tagName).toBe("A"); + expect(setShippingLink.tagName).toBe("BUTTON"); }); it("should call onShippingMethodEdit when 'Set shipping method' link is clicked", async () => { @@ -267,7 +267,7 @@ describe("OrderValue", () => { const methodLink = screen.getByText("Standard Shipping"); expect(methodLink).toBeInTheDocument(); - expect(methodLink.tagName).toBe("A"); + expect(methodLink.tagName).toBe("BUTTON"); await userEvent.click(methodLink); expect(onShippingMethodEdit).toHaveBeenCalledTimes(1); @@ -415,7 +415,8 @@ describe("OrderValue", () => { // Assert expect(screen.getByText("Discount")).toBeInTheDocument(); - expect(screen.getByText("15.00")).toBeInTheDocument(); + expect(screen.getByText("Fixed amount")).toBeInTheDocument(); + expect(screen.getByText("15")).toBeInTheDocument(); }); it("should show discount reason as tooltip on existing discount", () => { From c0cf716ea0861ba401a15038ed88c038e8b4de74 Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Tue, 16 Dec 2025 10:41:28 +0100 Subject: [PATCH 51/86] cleanup release workflow (#6193) * cleanup release workflow * fix workflow --- .github/workflows/cleanup-after-release.yml | 89 +++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/cleanup-after-release.yml diff --git a/.github/workflows/cleanup-after-release.yml b/.github/workflows/cleanup-after-release.yml new file mode 100644 index 00000000000..7961af45f23 --- /dev/null +++ b/.github/workflows/cleanup-after-release.yml @@ -0,0 +1,89 @@ +name: Cleanup After Release + +on: + push: + branches: + - "3.22" + +permissions: + contents: write + pull-requests: write + +jobs: + # When a release PR (changeset-release/3.22) is merged to the release branch, + # we need to sync those changes back to main to keep it up to date. + # This workflow creates a PR from the release branch to main for cleanup. + create-cleanup-pr: + runs-on: ubuntu-22.04 + # Only run if the push is from merging a PR (not direct commits) + if: github.event.head_commit.message != '' && contains(github.event.head_commit.message, 'Merge pull request') + env: + CURRENT_RELEASE: "3.22" + + steps: + - name: Get Token + id: get-token + uses: saleor/saleor-internal-actions/request-vault-token@v1.4.0 + with: + vault-url: ${{ secrets.VAULT_URL }} + vault-jwt: ${{ secrets.VAULT_JWT }} + + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ steps.get-token.outputs.token }} + fetch-depth: 0 # Fetch all history to get proper version info + + - name: Configure Git + run: | + git config user.name "Saleor Deployments" + git config user.email "deployments@saleor.io" + + - name: Check if merged PR was from changeset-release + id: check-release + run: | + # Get the commit message to check if it's from changeset-release branch + COMMIT_MSG="${{ github.event.head_commit.message }}" + + if echo "$COMMIT_MSG" | grep -q "from saleor/changeset-release/$CURRENT_RELEASE"; then + echo "is_release_merge=true" >> $GITHUB_OUTPUT + echo "✅ This is a release PR merge from changeset-release/$CURRENT_RELEASE" + else + echo "is_release_merge=false" >> $GITHUB_OUTPUT + echo "ℹ️ This is not a release PR merge, skipping cleanup PR creation" + fi + + - name: Get version from package.json + if: steps.check-release.outputs.is_release_merge == 'true' + id: get-version + run: | + VERSION=$(node -p "require('./package.json').version") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 Version: $VERSION" + + - name: Create cleanup PR + if: steps.check-release.outputs.is_release_merge == 'true' + env: + GH_TOKEN: ${{ steps.get-token.outputs.token }} + run: | + VERSION="${{ steps.get-version.outputs.version }}" + + # Fetch latest changes + git fetch origin + + echo "Creating new cleanup PR from $CURRENT_RELEASE to main" + + gh pr create \ + --base main \ + --head "$CURRENT_RELEASE" \ + --title "Clean up after release $VERSION" \ + --body "$(cat < Date: Tue, 16 Dec 2025 12:06:13 +0100 Subject: [PATCH 52/86] Fix customer->order filter redirection (#6213) --- .changeset/wide-signs-love.md | 5 +++++ .../components/CustomerDetailsPage/CustomerDetailsPage.tsx | 6 ++---- src/orders/urls.ts | 5 +++++ 3 files changed, 12 insertions(+), 4 deletions(-) create mode 100644 .changeset/wide-signs-love.md diff --git a/.changeset/wide-signs-love.md b/.changeset/wide-signs-love.md new file mode 100644 index 00000000000..116a7186ee9 --- /dev/null +++ b/.changeset/wide-signs-love.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Fixed redirection between Customer Details -> See all orders to Orders List (which this customer selected). Now filter is properly set on the URL and only relevant orders are displayed diff --git a/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx b/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx index ef2e02e7202..da10fac8dff 100644 --- a/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx +++ b/src/customers/components/CustomerDetailsPage/CustomerDetailsPage.tsx @@ -20,7 +20,7 @@ import { useBackLinkWithState } from "@dashboard/hooks/useBackLinkWithState"; import { SubmitPromise } from "@dashboard/hooks/useForm"; import useNavigator from "@dashboard/hooks/useNavigator"; import { sectionNames } from "@dashboard/intl"; -import { orderListUrl } from "@dashboard/orders/urls"; +import { orderListUrlWithCustomerEmail } from "@dashboard/orders/urls"; import { mapEdgesToItems, mapMetadataItemToInput } from "@dashboard/utils/maps"; import { Divider } from "@saleor/macaw-ui-next"; import { useIntl } from "react-intl"; @@ -111,9 +111,7 @@ const CustomerDetailsPage = ({ diff --git a/src/orders/urls.ts b/src/orders/urls.ts index 550e27a40cc..a0ebbf59dc6 100644 --- a/src/orders/urls.ts +++ b/src/orders/urls.ts @@ -78,6 +78,11 @@ export type OrderListUrlQueryParams = BulkAction & OrderListUrlSort & Pagination & ActiveTab; + +/** + * @deprecated + * This helper is likely broken, at least filters don't work. Either construct url manually or fix it + */ export const orderListUrl = (params?: OrderListUrlQueryParams): string => { const orderList = orderListPath; From 955f177b895f4c2b206b485fba71b7c9a26f7c0e Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Tue, 16 Dec 2025 12:06:32 +0100 Subject: [PATCH 53/86] Release 3.22 (#6211) (#6212) Co-authored-by: saleor-deployments[bot] <97954499+saleor-deployments[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .changeset/breezy-steaks-sit.md | 5 ----- .changeset/free-ravens-work.md | 5 ----- .changeset/fruity-hornets-jump.md | 5 ----- .changeset/good-hats-push.md | 5 ----- .changeset/lazy-worlds-prove.md | 5 ----- .changeset/small-baboons-eat.md | 5 ----- .changeset/two-pens-camp.md | 5 ----- .changeset/witty-kiwis-dress.md | 5 ----- CHANGELOG.md | 20 ++++++++++++++++++++ package.json | 2 +- 10 files changed, 21 insertions(+), 41 deletions(-) delete mode 100644 .changeset/breezy-steaks-sit.md delete mode 100644 .changeset/free-ravens-work.md delete mode 100644 .changeset/fruity-hornets-jump.md delete mode 100644 .changeset/good-hats-push.md delete mode 100644 .changeset/lazy-worlds-prove.md delete mode 100644 .changeset/small-baboons-eat.md delete mode 100644 .changeset/two-pens-camp.md delete mode 100644 .changeset/witty-kiwis-dress.md diff --git a/.changeset/breezy-steaks-sit.md b/.changeset/breezy-steaks-sit.md deleted file mode 100644 index a501643ca93..00000000000 --- a/.changeset/breezy-steaks-sit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Invalid event sent from app to dashboard will not throw anymore, but gracefully show notification diff --git a/.changeset/free-ravens-work.md b/.changeset/free-ravens-work.md deleted file mode 100644 index 44f72f6ed36..00000000000 --- a/.changeset/free-ravens-work.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Fix and redesign Order history diff --git a/.changeset/fruity-hornets-jump.md b/.changeset/fruity-hornets-jump.md deleted file mode 100644 index 348cebd395a..00000000000 --- a/.changeset/fruity-hornets-jump.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Fix main title overflow diff --git a/.changeset/good-hats-push.md b/.changeset/good-hats-push.md deleted file mode 100644 index bb5601a3f50..00000000000 --- a/.changeset/good-hats-push.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Cleanup order's Customer Details and Addresses sections diff --git a/.changeset/lazy-worlds-prove.md b/.changeset/lazy-worlds-prove.md deleted file mode 100644 index 4fb9be6de9f..00000000000 --- a/.changeset/lazy-worlds-prove.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -New label for ConfirmButton error state diff --git a/.changeset/small-baboons-eat.md b/.changeset/small-baboons-eat.md deleted file mode 100644 index e68fc243584..00000000000 --- a/.changeset/small-baboons-eat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Clipboard operations no longer crash the website if browser permissions are not enabled diff --git a/.changeset/two-pens-camp.md b/.changeset/two-pens-camp.md deleted file mode 100644 index ab759ce9175..00000000000 --- a/.changeset/two-pens-camp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Improve icons color consistency with timeline diff --git a/.changeset/witty-kiwis-dress.md b/.changeset/witty-kiwis-dress.md deleted file mode 100644 index 716e0a3a305..00000000000 --- a/.changeset/witty-kiwis-dress.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Added UI to control "automatic checkout completion" from the dashboard settings page. What previously was allowed only via graphQL, now can be controlled easily by staff. diff --git a/CHANGELOG.md b/CHANGELOG.md index 622f1f0650b..ca45b29bfa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 3.22.21 + +### Patch Changes + +- [#6196](https://github.com/saleor/saleor-dashboard/pull/6196) [`5f22cde`](https://github.com/saleor/saleor-dashboard/commit/5f22cded76c3736447dae769b6564fe6fd05e4d9) Thanks [@lkostrowski](https://github.com/lkostrowski)! - Invalid event sent from app to dashboard will not throw anymore, but gracefully show notification + +- [#6150](https://github.com/saleor/saleor-dashboard/pull/6150) [`e0f798c`](https://github.com/saleor/saleor-dashboard/commit/e0f798cf09b7fb761febdc57b653155644fe6329) Thanks [@mirekm](https://github.com/mirekm)! - Fix and redesign Order history + +- [#6205](https://github.com/saleor/saleor-dashboard/pull/6205) [`fb40e57`](https://github.com/saleor/saleor-dashboard/commit/fb40e57ab0c6a5842c87fbfe03c3e2486019e3b9) Thanks [@mirekm](https://github.com/mirekm)! - Fix main title overflow + +- [#6168](https://github.com/saleor/saleor-dashboard/pull/6168) [`c366f94`](https://github.com/saleor/saleor-dashboard/commit/c366f9487affa218ab04ff0b9c0ebfae54a585ed) Thanks [@mirekm](https://github.com/mirekm)! - Cleanup order's Customer Details and Addresses sections + +- [#6204](https://github.com/saleor/saleor-dashboard/pull/6204) [`dacee7d`](https://github.com/saleor/saleor-dashboard/commit/dacee7de926a45c8c8ee91284882e18fcdb35fae) Thanks [@mirekm](https://github.com/mirekm)! - New label for ConfirmButton error state + +- [#6196](https://github.com/saleor/saleor-dashboard/pull/6196) [`5f22cde`](https://github.com/saleor/saleor-dashboard/commit/5f22cded76c3736447dae769b6564fe6fd05e4d9) Thanks [@lkostrowski](https://github.com/lkostrowski)! - Clipboard operations no longer crash the website if browser permissions are not enabled + +- [#6206](https://github.com/saleor/saleor-dashboard/pull/6206) [`a0893de`](https://github.com/saleor/saleor-dashboard/commit/a0893deb0a4b78ba4c69a6a220d48049ac4af78c) Thanks [@mirekm](https://github.com/mirekm)! - Improve icons color consistency with timeline + +- [#6208](https://github.com/saleor/saleor-dashboard/pull/6208) [`bcb4854`](https://github.com/saleor/saleor-dashboard/commit/bcb485469a0327a518deb3d91dfb68b8501c8e61) Thanks [@lkostrowski](https://github.com/lkostrowski)! - Added UI to control "automatic checkout completion" from the dashboard settings page. What previously was allowed only via graphQL, now can be controlled easily by staff. + ## 3.22.20 ### Patch Changes diff --git a/package.json b/package.json index 3433df156b0..3260e2dcf69 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "saleor-dashboard", - "version": "3.22.20", + "version": "3.22.21", "repository": { "type": "git", "url": "git://github.com/saleor/saleor-dashboard.git" From c6848f5578aa3ef636b4c9d4eb1ad14957f62d91 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 16 Dec 2025 11:37:44 -0100 Subject: [PATCH 54/86] Update transactions (#6178) * Initial redesign of the history timeline * Improve the timeline view * Use Macau's Textarea for the comment * Fix the "Let's fix the comment's "dirty" state * Fix Order history header size * Clean up avatars a bit * Fix test * Nudges * Extract messages * Add changeset * Refactor and clean up * Add Cmd+Enter support for order notes * Extract messages * Use semantic color token for icons * Refactor * Clean up the design a bit * Fix tests * Fix Copilot's suggestions * Refactor and i18n date grouping * Use simpler `{props; Props}` * Refactor isMessageEmpty * Add hint about Cmd+K * Better error handling and memization of safeStringify * Simplify comment card and get rid of inline styles * More i18n * Add preliminary apps support * Address some more copilot feedback * Extract messages * More improvements * Add grouping by date tests * More tests * Externalise CSS instead of injecting * Drop default export * Refactor into subcomponents * Limit the safeStringify to the essentials considering our logic * Drop `any` assertion * Refactor shortcuts into a standalone component * Address copilot's suggestion * Add KeyboardShortcut tests * Simplify app attribution and drop link to appUrl * Link app to it's Dashboard's view * Add copying component * Further simplify and refactor * Extract messages * Get rid of default exports * Fix importing style * Improve lines handling * Improve list ids uniquness * Dont render date group header if <= 1 * Extract messages * Improve "an order" -> "the order" in event titles * Further refactor Timeline * Drop moment.js * Extract messages * Fix tests * Extract messages * Match Transactions and Refunds header size and weight * Turn transactions into foldable cards * Improve the event lines * Fix highlight color and minor tweaks * Adjust external PSP reference links * Try to fix width for the transaction event columns * More column fixes * Header nudges * Fix actions * Add changeset * Extract messages * Extend the transactions fixture * Fix types * Change linked events highlight to lighter * Icons in Macau are deprecated * Prefer type declarations over assertions * Use CSS modules * More sprinkles * Drop antipattern * Replace Macaw icon with Lucide counterparts * More tests * Add tests for CopyableText component * Improve CopyableText for better accessibility * Drop index file for CopyableText component * Accept also the 2nd key as a property in KeyboardShortcutHint * Turn CSS into CSS module * Simplify the avatar URL passing * Just make the `name` optional * Pass props only needed by the component * Rename component and drop barrle index export * Get rid of the timeout, just use animation * Simplify the date props * Let's drop `// @ts-strict-ignore` * Fix relative dates utils import and remove ts-strict-ignore * Simplify condition in getOrderNumberLink function * Improve keys generation * Refactor transaction fixture into its own file * Refactor the transaction title back to separate variable * Move util outside of the component * Refactor another util into a separate function * Handle invalid date * Move styles out to CSS module * Refactor Copy to clipboard message to the common intl * Make sure the listenre is cleared * Rename folder and file to match the new component name * Finish transition from generic keyboard hint to specific one * Split CSS module into smaller ones between the corresponding components * Move to a single actor union; Refactor some helpers to utils * Drop the barrel imports * Let the parent component decide the format of date display * Drop comment, improve clarity * Refactor to CSS module * Do the link Macaw way * Align chevron in OrderTransaction card title * Extract messages * Improve i18n * Extract messages --------- Co-authored-by: Lukasz Ostrowski --- .changeset/crisp-shoes-care.md | 5 + locale/defaultMessages.json | 11 + src/components/CopyableText/CopyableText.tsx | 7 +- src/intl.ts | 10 + .../OrderTransaction.module.css | 13 + .../OrderTransaction/OrderTransaction.tsx | 72 ++++-- .../components/CardTitle/CardTitle.test.tsx | 165 +++++++++++++ .../components/CardTitle/CardTitle.tsx | 226 ++++++++++-------- .../components/CardTitle/MoneyDisplay.tsx | 12 +- .../TransactionEvents/TransactionEvents.tsx | 25 +- .../components/EventAvatar.tsx | 79 ++++++ .../components/EventItem.test.tsx | 57 ++--- .../components/EventItem.tsx | 121 ++++------ .../components/EventTime.tsx | 15 +- .../components/EventType.module.css | 7 + .../components/EventType.tsx | 34 +-- .../components/PspReference.module.css | 17 ++ .../components/PspReference.tsx | 159 ++++++------ .../components/PspReferenceLink.tsx | 30 +-- src/orders/fixtures/OrderFixture.ts | 204 ++++++++++++---- src/orders/fixtures/TransactionFixture.ts | 188 +++++++++++++++ 21 files changed, 1068 insertions(+), 389 deletions(-) create mode 100644 .changeset/crisp-shoes-care.md create mode 100644 src/orders/components/OrderTransaction/OrderTransaction.module.css create mode 100644 src/orders/components/OrderTransaction/components/CardTitle/CardTitle.test.tsx create mode 100644 src/orders/components/OrderTransaction/components/TransactionEvents/components/EventAvatar.tsx create mode 100644 src/orders/components/OrderTransaction/components/TransactionEvents/components/EventType.module.css create mode 100644 src/orders/components/OrderTransaction/components/TransactionEvents/components/PspReference.module.css create mode 100644 src/orders/fixtures/TransactionFixture.ts diff --git a/.changeset/crisp-shoes-care.md b/.changeset/crisp-shoes-care.md new file mode 100644 index 00000000000..8768c0d52e0 --- /dev/null +++ b/.changeset/crisp-shoes-care.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Update order Transactions cards diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index a6659372443..357a8e636db 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -6082,6 +6082,10 @@ "context": "ProductTypeDeleteWarningDialog single assigned items description", "string": "You are about to delete product type {typeName}. It is assigned to {assignedItemsCount} {assignedItemsCount,plural,one{product} other{products}}. Deleting this product type will also delete those products. Are you sure you want to do this?" }, + "ZGmd4h": { + "context": "button", + "string": "Copy to clipboard" + }, "ZHF4Z9": { "context": "delete product dialog subtitle", "string": "Are you sure you want to delete {name}?" @@ -6682,6 +6686,9 @@ "context": "WarehouseSettings no shipping zones assigned", "string": "This warehouse has no shipping zones assigned." }, + "ce2kVF": { + "string": "View in payment provider" + }, "ce5Hp1": { "string": "Failed to copy to clipboard" }, @@ -8405,6 +8412,10 @@ "context": "PageTypeDeleteWarningDialog title", "string": "Delete page {selectedTypesCount,plural,one{type} other{types}}" }, + "oIS3NK": { + "context": "button", + "string": "Show more" + }, "oIvtua": { "context": "attribute's editor component", "string": "Catalog Input type for Store Owner" diff --git a/src/components/CopyableText/CopyableText.tsx b/src/components/CopyableText/CopyableText.tsx index 96e3732734a..a2aaa124e7b 100644 --- a/src/components/CopyableText/CopyableText.tsx +++ b/src/components/CopyableText/CopyableText.tsx @@ -1,4 +1,5 @@ import { useClipboard } from "@dashboard/hooks/useClipboard"; +import { buttonMessages } from "@dashboard/intl"; import { Box, Button, sprinkles, Text } from "@saleor/macaw-ui-next"; import { CheckIcon, CopyIcon } from "lucide-react"; import { useState } from "react"; @@ -42,10 +43,8 @@ export const CopyableText = ({ text }: CopyableTextProps): JSX.Element => { ) } onClick={() => copy(text)} - aria-label={intl.formatMessage({ - defaultMessage: "Copy to clipboard", - id: "aCdAsI", - })} + title={intl.formatMessage(buttonMessages.copyToClipboard)} + aria-label={intl.formatMessage(buttonMessages.copyToClipboard)} /> diff --git a/src/intl.ts b/src/intl.ts index 354590ebe2f..7d1d0ef715e 100644 --- a/src/intl.ts +++ b/src/intl.ts @@ -340,6 +340,11 @@ export const buttonMessages = defineMessages({ defaultMessage: "Manage", description: "button", }, + moreOptions: { + id: "oIS3NK", + defaultMessage: "Show more", + description: "button", + }, nextStep: { id: "wlQTfb", defaultMessage: "Next", @@ -404,6 +409,11 @@ export const buttonMessages = defineMessages({ id: "rbrahO", defaultMessage: "Close", }, + copyToClipboard: { + id: "ZGmd4h", + defaultMessage: "Copy to clipboard", + description: "button", + }, proceed: { id: "VNX4fn", defaultMessage: "Proceed", diff --git a/src/orders/components/OrderTransaction/OrderTransaction.module.css b/src/orders/components/OrderTransaction/OrderTransaction.module.css new file mode 100644 index 00000000000..3ce7adf995d --- /dev/null +++ b/src/orders/components/OrderTransaction/OrderTransaction.module.css @@ -0,0 +1,13 @@ +/* Chevron rotation for accordion */ +.chevron { + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; + transform: rotate(-90deg); +} + +button[data-state="open"] .chevron, +[data-state="open"] .chevron { + transform: rotate(0deg); +} diff --git a/src/orders/components/OrderTransaction/OrderTransaction.tsx b/src/orders/components/OrderTransaction/OrderTransaction.tsx index 9b743d428a5..e16beef8811 100644 --- a/src/orders/components/OrderTransaction/OrderTransaction.tsx +++ b/src/orders/components/OrderTransaction/OrderTransaction.tsx @@ -1,11 +1,15 @@ // @ts-strict-ignore -import { DashboardCard } from "@dashboard/components/Card"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; import { TransactionActionEnum } from "@dashboard/graphql"; import { TransactionFakeEvent } from "@dashboard/orders/types"; +import { Accordion, Box } from "@saleor/macaw-ui-next"; +import { ChevronDown } from "lucide-react"; import * as React from "react"; +import { useState } from "react"; import { OrderTransactionCardTitle } from "./components"; import { TransactionEvents } from "./components/TransactionEvents"; +import styles from "./OrderTransaction.module.css"; import { ExtendedOrderTransaction } from "./types"; import { getTransactionEvents } from "./utils"; @@ -16,6 +20,7 @@ export interface OrderTransactionProps { showActions?: boolean; cardFooter?: React.ReactNode; disabled?: boolean; + defaultExpanded?: boolean; } const OrderTransaction = ({ @@ -25,24 +30,63 @@ const OrderTransaction = ({ showActions, cardFooter, disabled = false, + defaultExpanded = true, }: OrderTransactionProps) => { const events = getTransactionEvents(transaction, fakeEvents); + const [expanded, setExpanded] = useState( + defaultExpanded ? transaction.id : undefined, + ); return ( - - - - + + + + + + + + + + } + /> + + - - - {cardFooter} - - + + + + {cardFooter} + + + + + + ); }; diff --git a/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.test.tsx b/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.test.tsx new file mode 100644 index 00000000000..0c8b9e66368 --- /dev/null +++ b/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.test.tsx @@ -0,0 +1,165 @@ +import { TransactionActionEnum, TransactionItemFragment } from "@dashboard/graphql"; +import { prepareMoney, transactions } from "@dashboard/orders/fixtures"; +import Wrapper from "@test/wrapper"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { ExtendedOrderTransaction } from "../../types"; +import { OrderTransactionCardTitle } from "./CardTitle"; + +const createTransaction = ( + overrides: Partial & { index?: number } = {}, +): ExtendedOrderTransaction => ({ + ...transactions.chargeSuccess[0], + index: 0, + ...overrides, +}); + +describe("OrderTransactionCardTitle", () => { + describe("amounts display logic", () => { + it("only displays amounts greater than zero", () => { + // Arrange + const transaction = createTransaction({ + chargedAmount: prepareMoney(100), + authorizedAmount: prepareMoney(0), + refundedAmount: prepareMoney(50), + canceledAmount: prepareMoney(0), + chargePendingAmount: prepareMoney(0), + authorizePendingAmount: prepareMoney(0), + refundPendingAmount: prepareMoney(0), + cancelPendingAmount: prepareMoney(0), + }); + + // Act + render( + + + , + ); + + // Assert - only non-zero amounts should be displayed + expect(screen.getByText("Charged")).toBeInTheDocument(); + expect(screen.getByText("Refunded")).toBeInTheDocument(); + expect(screen.queryByText("Authorized")).not.toBeInTheDocument(); + expect(screen.queryByText("Canceled")).not.toBeInTheDocument(); + }); + }); + + describe("actions menu logic", () => { + it("filters out REFUND action from menu", async () => { + // Arrange + const user = userEvent.setup(); + const transaction = createTransaction({ + actions: [ + TransactionActionEnum.CHARGE, + TransactionActionEnum.REFUND, + TransactionActionEnum.CANCEL, + ], + }); + + // Act + render( + + + , + ); + await user.click(screen.getByTestId("transaction-menu-button")); + + // Assert - REFUND is handled separately in Send Refund view + expect(screen.getByText("Capture")).toBeInTheDocument(); + expect(screen.getByText("Cancel")).toBeInTheDocument(); + expect(screen.queryByText("Refund")).not.toBeInTheDocument(); + }); + + it("hides menu when showActions is false even if actions exist", () => { + // Arrange + const transaction = createTransaction({ + actions: [TransactionActionEnum.CHARGE, TransactionActionEnum.CANCEL], + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.queryByTestId("transaction-menu-button")).not.toBeInTheDocument(); + }); + + it("hides menu when actions array is empty", () => { + // Arrange + const transaction = createTransaction({ actions: [] }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.queryByTestId("transaction-menu-button")).not.toBeInTheDocument(); + }); + + it("hides menu when only REFUND action is available", () => { + // Arrange - REFUND gets filtered out, leaving no actions + const transaction = createTransaction({ + actions: [TransactionActionEnum.REFUND], + }); + + // Act + render( + + + , + ); + + // Assert + expect(screen.queryByTestId("transaction-menu-button")).not.toBeInTheDocument(); + }); + + it("calls onTransactionAction with correct transaction id and action type", async () => { + // Arrange + const user = userEvent.setup(); + const onTransactionAction = jest.fn(); + const transaction = createTransaction({ + id: "txn-abc-123", + actions: [TransactionActionEnum.CANCEL], + }); + + // Act + render( + + + , + ); + await user.click(screen.getByTestId("transaction-menu-button")); + await user.click(screen.getByText("Cancel")); + + // Assert + expect(onTransactionAction).toHaveBeenCalledWith("txn-abc-123", TransactionActionEnum.CANCEL); + }); + }); +}); diff --git a/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.tsx b/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.tsx index 27445a86040..9246f1ffec9 100644 --- a/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.tsx +++ b/src/orders/components/OrderTransaction/components/CardTitle/CardTitle.tsx @@ -1,8 +1,8 @@ -import { ButtonLink } from "@dashboard/components/ButtonLink"; -import { iconSize, iconStrokeWidth } from "@dashboard/components/icons"; +import { iconSize, iconStrokeWidthBySize } from "@dashboard/components/icons"; import { TransactionActionEnum } from "@dashboard/graphql"; -import { Box, Button, Text } from "@saleor/macaw-ui-next"; -import { ExternalLink } from "lucide-react"; +import { buttonMessages } from "@dashboard/intl"; +import { Box, Button, Dropdown, List, Text } from "@saleor/macaw-ui-next"; +import { ExternalLink, MoreVertical } from "lucide-react"; import { FormattedMessage, useIntl } from "react-intl"; import { OrderTransactionProps } from "../../OrderTransaction"; @@ -12,18 +12,61 @@ import { EventTime } from "../TransactionEvents/components/EventTime"; import { messages } from "./messages"; import { MoneyDisplay } from "./MoneyDisplay"; +const isDestructiveAction = (action: TransactionActionEnum) => + action === TransactionActionEnum.CANCEL || action === TransactionActionEnum.REFUND; + +const getTransactionAmounts = ({ + chargedAmount, + authorizedAmount, + refundedAmount, + canceledAmount, + chargePendingAmount, + authorizePendingAmount, + refundPendingAmount, + cancelPendingAmount, +}: ExtendedOrderTransaction) => + [ + { label: messages.charged, money: chargedAmount, show: chargedAmount.amount > 0 }, + { label: messages.authorized, money: authorizedAmount, show: authorizedAmount.amount > 0 }, + { label: messages.refunded, money: refundedAmount, show: refundedAmount.amount > 0 }, + { label: messages.canceled, money: canceledAmount, show: canceledAmount.amount > 0 }, + { + label: messages.chargePending, + money: chargePendingAmount, + show: chargePendingAmount.amount > 0, + }, + { + label: messages.authorizePending, + money: authorizePendingAmount, + show: authorizePendingAmount.amount > 0, + }, + { + label: messages.refundPending, + money: refundPendingAmount, + show: refundPendingAmount.amount > 0, + }, + { + label: messages.cancelPending, + money: cancelPendingAmount, + show: cancelPendingAmount.amount > 0, + }, + ].filter(item => item.show); + interface CardTitleProps { transaction: ExtendedOrderTransaction; onTransactionAction: OrderTransactionProps["onTransactionAction"]; showActions?: boolean; + chevron?: React.ReactNode; } const TransactionTitle = ({ transaction, index, + chevron, }: { transaction: ExtendedOrderTransaction; index: number; + chevron?: React.ReactNode; }) => { const intl = useIntl(); @@ -39,10 +82,34 @@ const TransactionTitle = ({ ); return ( - - {transactionTitle} + + + {chevron} + + {transactionTitle} + + {transaction.externalUrl && ( + e.stopPropagation()} + color={{ default: "default2", hover: "default1" }} + __transition="color 0.15s ease-in-out" + title={intl.formatMessage({ + defaultMessage: "View in payment provider", + id: "ce2kVF", + })} + > + + + )} + {transaction.name && ( - + {transaction.name} )} @@ -54,104 +121,67 @@ export const OrderTransactionCardTitle = ({ transaction, onTransactionAction, showActions = true, + chevron, }: CardTitleProps) => { const intl = useIntl(); - const { - refundedAmount, - refundPendingAmount, - authorizePendingAmount, - cancelPendingAmount, - chargePendingAmount, - canceledAmount, - chargedAmount, - authorizedAmount, - index = 0, - } = transaction; + const { index = 0 } = transaction; const actions = transaction.actions.filter(action => action !== TransactionActionEnum.REFUND); - const showActionButtons = showActions && actions.length > 0; + const showActionsMenu = showActions && actions.length > 0; - return ( - - {transaction.externalUrl ? ( - - + // Collect all non-zero amounts for display + const amounts = getTransactionAmounts(transaction); - - - ) : ( - - - - )} - - - {cancelPendingAmount.amount > 0 && ( - - )} - - {canceledAmount.amount > 0 && ( - - )} - - {refundPendingAmount.amount > 0 && ( - - )} - - {refundedAmount.amount > 0 && ( - - )} - - {chargePendingAmount.amount > 0 && ( - - )} - - {chargedAmount.amount > 0 && ( - - )} - - {authorizePendingAmount.amount > 0 && ( - - )} - - {authorizedAmount.amount > 0 && ( - - )} - - {showActionButtons && ( - - {actions.map(action => ( -
- -
- ))} -
+ return ( + + + + + {amounts.map(({ label, money }) => ( + + ))} + + {showActionsMenu && ( + + + )} {canRefund && ( @@ -51,26 +50,17 @@ export const LegacyPaymentsApiButtons = ({ onClick={onLegacyPaymentsApiRefund} data-test-id="refund-button" > - {intl.formatMessage({ - defaultMessage: "Refund", - id: "IeUH3/", - })} + {intl.formatMessage(transactionActionMessages.refund)} )} {canVoid && ( )} {canMarkAsPaid && onMarkAsPaid && ( )} diff --git a/src/orders/components/OrderSummary/TransactionsApiButtons.tsx b/src/orders/components/OrderSummary/TransactionsApiButtons.tsx index a2aeb213376..8eb01823d7f 100644 --- a/src/orders/components/OrderSummary/TransactionsApiButtons.tsx +++ b/src/orders/components/OrderSummary/TransactionsApiButtons.tsx @@ -2,6 +2,8 @@ import { Button } from "@saleor/macaw-ui-next"; import { CheckIcon } from "lucide-react"; import { useIntl } from "react-intl"; +import { transactionActionMessages } from "../OrderTransaction/messages"; + type Props = { hasNoPayment: boolean; canMarkAsPaid: boolean; @@ -18,10 +20,7 @@ export const TransactionsApiButtons = ({ hasNoPayment, canMarkAsPaid, onMarkAsPa return ( ); }; diff --git a/src/orders/components/OrderTransaction/messages.ts b/src/orders/components/OrderTransaction/messages.ts index 077c74cdc86..69e6e095155 100644 --- a/src/orders/components/OrderTransaction/messages.ts +++ b/src/orders/components/OrderTransaction/messages.ts @@ -16,4 +16,14 @@ export const transactionActionMessages = defineMessages({ id: "iIfq2+", description: "Transaction cancel button - return preauthorized amount to client", }, + refund: { + defaultMessage: "Refund", + description: "Transaction refund button - return captured amount to client", + id: "8HmEqK", + }, + markAsPaid: { + defaultMessage: "Mark as Paid", + description: "Button to manually mark order as paid without actual payment", + id: "SDgGcU", + }, }); From fd03505f225a7e00062edacb4dbfa589a9c88414 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 16 Dec 2025 20:57:38 -0100 Subject: [PATCH 56/86] Let's use Box instead of custom CSS for PaymentsSummaryEmptyState component --- .../PaymentsSummaryEmptyState.module.css | 26 ------------------- .../PaymentsSummaryEmptyState.tsx | 24 +++++++++++++---- 2 files changed, 19 insertions(+), 31 deletions(-) delete mode 100644 src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css diff --git a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css deleted file mode 100644 index 513aaa5996c..00000000000 --- a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.module.css +++ /dev/null @@ -1,26 +0,0 @@ -.emptyState { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px 16px; - gap: 0; -} - -.iconWrapper { - display: flex; - align-items: center; - justify-content: center; - width: 56px; - height: 56px; - border-radius: 50%; - background-color: var(--macaw-color-background-default2); -} - -.textContainer { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - text-align: center; -} diff --git a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx index 4f80454f6a9..b77ce891f5f 100644 --- a/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx +++ b/src/orders/components/OrderSummary/PaymentsSummaryEmptyState.tsx @@ -2,17 +2,31 @@ import { Box, Text } from "@saleor/macaw-ui-next"; import { CreditCard } from "lucide-react"; import { useIntl } from "react-intl"; -import styles from "./PaymentsSummaryEmptyState.module.css"; - export const PaymentsSummaryEmptyState = () => { const intl = useIntl(); return ( - - + + - + {intl.formatMessage({ defaultMessage: "No payment received", From 1de52c705404b7f19f65626ceac9aa731482adae Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 16 Dec 2025 21:32:16 -0100 Subject: [PATCH 57/86] Improve the contract on Mark as Paid --- src/orders/components/OrderSummary/TransactionsApiButtons.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orders/components/OrderSummary/TransactionsApiButtons.tsx b/src/orders/components/OrderSummary/TransactionsApiButtons.tsx index 8eb01823d7f..6af193fcb11 100644 --- a/src/orders/components/OrderSummary/TransactionsApiButtons.tsx +++ b/src/orders/components/OrderSummary/TransactionsApiButtons.tsx @@ -7,13 +7,13 @@ import { transactionActionMessages } from "../OrderTransaction/messages"; type Props = { hasNoPayment: boolean; canMarkAsPaid: boolean; - onMarkAsPaid?: () => void; + onMarkAsPaid: () => void; }; export const TransactionsApiButtons = ({ hasNoPayment, canMarkAsPaid, onMarkAsPaid }: Props) => { const intl = useIntl(); - if (!hasNoPayment || !canMarkAsPaid || !onMarkAsPaid) { + if (!hasNoPayment || !canMarkAsPaid) { return null; } From 28a013c3636f2cbd94451f0a6b17fb9e557dee5d Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 16 Dec 2025 21:32:56 -0100 Subject: [PATCH 58/86] Improve the change description --- .changeset/lemon-candies-crash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lemon-candies-crash.md diff --git a/.changeset/lemon-candies-crash.md b/.changeset/lemon-candies-crash.md new file mode 100644 index 00000000000..25143609154 --- /dev/null +++ b/.changeset/lemon-candies-crash.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Redesigned Order Summary section with unified payment status display for both (legacy) Payments API and Transactions API From 25301a22e97b3f710005e08ac6bedb89c7907ac4 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 08:27:09 -0100 Subject: [PATCH 59/86] Remove the prior changelog files --- .changeset/deep-ducks-sniff.md | 5 ----- .changeset/few-bushes-ask.md | 5 ----- 2 files changed, 10 deletions(-) delete mode 100644 .changeset/deep-ducks-sniff.md delete mode 100644 .changeset/few-bushes-ask.md diff --git a/.changeset/deep-ducks-sniff.md b/.changeset/deep-ducks-sniff.md deleted file mode 100644 index 642e6dffc9c..00000000000 --- a/.changeset/deep-ducks-sniff.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Improve Order summary section diff --git a/.changeset/few-bushes-ask.md b/.changeset/few-bushes-ask.md deleted file mode 100644 index 2619b8d8bf7..00000000000 --- a/.changeset/few-bushes-ask.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"saleor-dashboard": patch ---- - -Unify Draft and Unconfirmed order info with the rest of the statuses From 70500a9acf8aaf04c5c380e8cb192dd9fd04e000 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 08:38:48 -0100 Subject: [PATCH 60/86] Render only when onMarkAsPaid is defined --- src/orders/components/OrderSummary/OrderSummary.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/orders/components/OrderSummary/OrderSummary.tsx b/src/orders/components/OrderSummary/OrderSummary.tsx index bbcc665a9f8..516f9460c1f 100644 --- a/src/orders/components/OrderSummary/OrderSummary.tsx +++ b/src/orders/components/OrderSummary/OrderSummary.tsx @@ -82,11 +82,13 @@ export const OrderSummary = (props: Props) => { } /> ) : ( - + onMarkAsPaid && ( + + ) )} From ee30448b4322c7c7ce501cde828621690bf2a4a2 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 09:48:11 -0100 Subject: [PATCH 61/86] Improve changelog desc --- .changeset/lemon-candies-crash.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lemon-candies-crash.md b/.changeset/lemon-candies-crash.md index 25143609154..d6b03bcff17 100644 --- a/.changeset/lemon-candies-crash.md +++ b/.changeset/lemon-candies-crash.md @@ -2,4 +2,4 @@ "saleor-dashboard": patch --- -Redesigned Order Summary section with unified payment status display for both (legacy) Payments API and Transactions API +Introduced a redesigned "Order summary" section that unifies order details and payment information across all order types, including Drafts and Unconfirmed orders. The updated "Order value" breakdown now clearly separates subtotal, taxes, discounts, and shipping. Additionally, a new "Payments summary" section has been added, featuring a dedicated "no data" state when no payments are present. From bf5cd908b71192208b0607368f0c3f140ca91700 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:11:31 -0100 Subject: [PATCH 62/86] Refactor to use defaultZeroMoney constant for default money values --- .../OrderUnconfirmedDetails/index.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index 2b249a7b740..c833724edee 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -169,6 +169,7 @@ export const OrderUnconfirmedDetails = ({ const intl = useIntl(); const [transactionReference, setTransactionReference] = useState(""); const errors = orderUpdate.opts.data?.orderUpdate.errors || []; + const defaultZeroMoney = { amount: 0, currency: "USD" }; const hasOrderFulfillmentsFulFilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, @@ -372,20 +373,14 @@ export const OrderUnconfirmedDetails = ({ t.id === params.id)?.authorizedAmount ?? { - amount: 0, - currency: "USD", - } + order?.transactions?.find(t => t.id === params.id)?.authorizedAmount ?? defaultZeroMoney } chargedAmount={ - order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? { - amount: 0, - currency: "USD", - } + order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? defaultZeroMoney } - orderBalance={order?.totalBalance ?? { amount: 0, currency: "USD" }} + orderBalance={order?.totalBalance ?? defaultZeroMoney} isTransaction open={true} onClose={closeModal} @@ -438,8 +433,8 @@ export const OrderUnconfirmedDetails = ({ From 0a8d88ff5f23c170bb57b0f50210b6d5537c8ebf Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:18:30 -0100 Subject: [PATCH 63/86] Optimise transaction amount retrieval with useMemo --- .../OrderDetails/OrderUnconfirmedDetails/index.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index c833724edee..b8c23068d1f 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -34,7 +34,7 @@ import { OrderLineDiscountProvider } from "@dashboard/products/components/OrderD import { useOrderVariantSearch } from "@dashboard/searches/useOrderVariantSearch"; import { PartialMutationProviderOutput } from "@dashboard/types"; import { mapEdgesToItems } from "@dashboard/utils/maps"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { customerUrl } from "../../../../customers/urls"; @@ -170,6 +170,10 @@ export const OrderUnconfirmedDetails = ({ const [transactionReference, setTransactionReference] = useState(""); const errors = orderUpdate.opts.data?.orderUpdate.errors || []; const defaultZeroMoney = { amount: 0, currency: "USD" }; + const selectedTransaction = useMemo( + () => order?.transactions?.find(t => t.id === params.id), + [order?.transactions, params.id], + ); const hasOrderFulfillmentsFulFilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, @@ -374,12 +378,8 @@ export const OrderUnconfirmedDetails = ({ confirmButtonState={orderTransactionAction.opts.status} errors={orderTransactionAction.opts.data?.transactionRequestAction?.errors ?? []} orderTotal={order?.total.gross ?? defaultZeroMoney} - authorizedAmount={ - order?.transactions?.find(t => t.id === params.id)?.authorizedAmount ?? defaultZeroMoney - } - chargedAmount={ - order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? defaultZeroMoney - } + authorizedAmount={selectedTransaction?.authorizedAmount ?? defaultZeroMoney} + chargedAmount={selectedTransaction?.chargedAmount ?? defaultZeroMoney} orderBalance={order?.totalBalance ?? defaultZeroMoney} isTransaction open={true} From 95360896f862d12ff4aa476dfac6fd6106bf6f57 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:27:17 -0100 Subject: [PATCH 64/86] Add separate transaction-charge-action URL param; Fixed data refetch after transaction actions --- src/orders/urls.ts | 3 ++- .../views/OrderDetails/OrderDetails.tsx | 7 +++++- .../OrderDetails/OrderNormalDetails/index.tsx | 24 ++++++++++++------- .../OrderUnconfirmedDetails/index.tsx | 24 ++++++++++++------- 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/orders/urls.ts b/src/orders/urls.ts index a0ebbf59dc6..fe1755ee71c 100644 --- a/src/orders/urls.ts +++ b/src/orders/urls.ts @@ -209,6 +209,7 @@ export type OrderUrlDialog = | "mark-paid" | "void" | "transaction-action" + | "transaction-charge-action" | "invoice-send" | "add-manual-transaction" | "view-order-line-metadata" @@ -216,7 +217,7 @@ export type OrderUrlDialog = | "view-fulfillment-metadata"; interface TransactionAction { - action: "transaction-action"; + action: "transaction-action" | "transaction-charge-action"; id: string; type: TransactionActionEnum; } diff --git a/src/orders/views/OrderDetails/OrderDetails.tsx b/src/orders/views/OrderDetails/OrderDetails.tsx index 5c5b43609df..cdfb1b2f3e5 100644 --- a/src/orders/views/OrderDetails/OrderDetails.tsx +++ b/src/orders/views/OrderDetails/OrderDetails.tsx @@ -138,7 +138,12 @@ const OrderDetails = ({ id, params }: OrderDetailsProps) => { } }} onInvoiceSend={orderMessages.handleInvoiceSend} - onTransactionActionSend={orderMessages.handleTransactionAction} + onTransactionActionSend={async data => { + await apolloClient.refetchQueries({ + include: [OrderDetailsWithMetadataDocument], + }); + orderMessages.handleTransactionAction(data); + }} onManualTransactionAdded={async data => { await apolloClient.refetchQueries({ include: [OrderDetailsWithMetadataDocument], diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 218c30de4f0..dffac2305d1 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -232,11 +232,19 @@ export const OrderNormalDetails = ({ onOrderShowMetadata={() => openModal("view-order-metadata")} onFulfillmentShowMetadata={id => openModal("view-fulfillment-metadata", { id })} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderFulfill={() => navigate(orderFulfillUrl(id))} onFulfillmentApprove={fulfillmentId => @@ -305,7 +313,7 @@ export const OrderNormalDetails = ({ } /> {/* Transaction Capture Dialog - for CHARGE action */} - {params.action === "transaction-action" && params.type === TransactionActionEnum.CHARGE && ( + {params.action === "transaction-charge-action" && ( orderTransactionAction diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index b8c23068d1f..0340111c657 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -219,11 +219,19 @@ export const OrderUnconfirmedDetails = ({ order={order} shop={shop} onTransactionAction={(id, action) => - openModal("transaction-action", { - type: action, - id, - action: "transaction-action", - }) + openModal( + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + { + type: action, + id, + action: + action === TransactionActionEnum.CHARGE + ? "transaction-charge-action" + : "transaction-action", + }, + ) } onOrderLineAdd={() => openModal("add-order-line")} onOrderLineChange={(id, data) => @@ -373,7 +381,7 @@ export const OrderUnconfirmedDetails = ({ handleTransactionReference={({ target }) => setTransactionReference(target.value)} /> {/* Transaction Capture Dialog - for CHARGE action */} - {params.action === "transaction-action" && params.type === TransactionActionEnum.CHARGE && ( + {params.action === "transaction-charge-action" && ( orderTransactionAction From a78b453f18894bfa7c29225df8cf85569af7613d Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:39:17 -0100 Subject: [PATCH 65/86] Simplify the summary message --- locale/defaultMessages.json | 16 ++--- .../OrderCaptureDialog/OrderCaptureDialog.tsx | 66 +++++++------------ .../components/OrderCaptureDialog/messages.ts | 18 ++--- 3 files changed, 32 insertions(+), 68 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 357a8e636db..a470ecbb363 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -815,6 +815,10 @@ "context": "filter range separator", "string": "and" }, + "34wIzn": { + "context": "outcome prediction showing resulting order status after capture", + "string": "This will result in {status} order" + }, "38dS1A": { "context": "code ending with label", "string": "Code ending with {last4CodeChars}" @@ -2459,10 +2463,6 @@ "DWWw3M": { "string": "Model type Name" }, - "DXaxpH": { - "context": "outcome prediction when order will be overcharged", - "string": "This will result in an {status} order" - }, "DaPGcn": { "string": "Model title" }, @@ -5470,10 +5470,6 @@ "context": "command menu shortcut", "string": "Command menu" }, - "VwCTbx": { - "context": "outcome prediction when order will be fully charged", - "string": "This will result in a {status} order" - }, "VxStyU": { "context": "order history message", "string": "Replacement order was created" @@ -10059,10 +10055,6 @@ "context": "warehouses section name", "string": "Warehouses" }, - "ycg2RR": { - "context": "outcome prediction when order will be partially charged", - "string": "This will result in a {status} order" - }, "ychKsb": { "context": "error message", "string": "Shipping method is required for this order" diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index 01cf228a148..bed32dd629f 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -489,48 +489,30 @@ export const OrderCaptureDialog = ({ {canSubmit && selectedAmount > 0 && ( - {outcomeStatus === "overcharged" && ( - - ), - }} - /> - )} - {outcomeStatus === "fullyCharged" && ( - - ), - }} - /> - )} - {outcomeStatus === "partiallyCharged" && ( - - ), - }} - /> - )} + + ), + }} + /> )} diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts index 21416d288c6..2a3b21831b4 100644 --- a/src/orders/components/OrderCaptureDialog/messages.ts +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -113,20 +113,10 @@ export const messages = defineMessages({ "No payment has been authorized for this order. The full amount of {amount} cannot be captured.", description: "error when no authorization exists", }, - outcomeFullyCharged: { - id: "VwCTbx", - defaultMessage: "This will result in a {status} order", - description: "outcome prediction when order will be fully charged", - }, - outcomePartiallyCharged: { - id: "ycg2RR", - defaultMessage: "This will result in a {status} order", - description: "outcome prediction when order will be partially charged", - }, - outcomeOvercharged: { - id: "DXaxpH", - defaultMessage: "This will result in an {status} order", - description: "outcome prediction when order will be overcharged", + outcomeMessage: { + id: "HSYM17", + defaultMessage: "This will result in {status} order", + description: "outcome prediction showing resulting order status after capture", }, statusFullyCapturedPill: { id: "G9y5Ze", From e6677cd36a134cf7fd915e51f5494f423f0f6e07 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:49:14 -0100 Subject: [PATCH 66/86] Extract messages --- locale/defaultMessages.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 6d5952515b8..5f8c7917784 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -812,10 +812,6 @@ "context": "filter range separator", "string": "and" }, - "34wIzn": { - "context": "outcome prediction showing resulting order status after capture", - "string": "This will result in {status} order" - }, "38dS1A": { "context": "code ending with label", "string": "Code ending with {last4CodeChars}" @@ -3131,6 +3127,10 @@ "context": "cta button label", "string": "Get in touch" }, + "HSYM17": { + "context": "outcome prediction showing resulting order status after capture", + "string": "This will result in {status} order" + }, "HSmg1/": { "context": "gift cards section name", "string": "Gift Cards" From e4186e9080b6523631e0b8c407197f21f793fd3e Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Wed, 17 Dec 2025 23:49:56 -0100 Subject: [PATCH 67/86] Fix tests --- .../components/OrderCaptureDialog/OrderCaptureDialog.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx index 171c5b61da6..fecaa540693 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -431,7 +431,7 @@ describe("OrderCaptureDialog", () => { }); // Assert - outcome prediction message is shown - expect(screen.getByText(/This will result in a/)).toBeInTheDocument(); + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); }); it("shows outcome message when capturing partial balance", () => { @@ -442,7 +442,7 @@ describe("OrderCaptureDialog", () => { }); // Assert - outcome prediction message is shown - expect(screen.getByText(/This will result in a/)).toBeInTheDocument(); + expect(screen.getByText(/This will result in/)).toBeInTheDocument(); }); }); From b5019303a171b37fa1b92c1882bcbe776a87ee95 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 26 Dec 2025 15:21:01 +0100 Subject: [PATCH 68/86] Refactor selected transaction lookup --- .../OrderDetails/OrderNormalDetails/index.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index dffac2305d1..2ffdd51a85b 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -47,7 +47,7 @@ import { OpenModalFunction, } from "@dashboard/utils/handlers/dialogActionHandlers"; import { mapEdgesToItems } from "@dashboard/utils/maps"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useIntl } from "react-intl"; import { customerUrl } from "../../../../customers/urls"; @@ -175,6 +175,12 @@ export const OrderNormalDetails = ({ const errors = orderUpdate.opts.data?.orderUpdate.errors || []; + const defaultZeroMoney = { amount: 0, currency: "USD" }; + const selectedTransaction = useMemo( + () => order?.transactions?.find(t => t.id === params.id), + [order?.transactions, params.id], + ); + const hasOrderFulfillmentsFulfilled = order?.fulfillments.some( fulfillment => fulfillment.status === FulfillmentStatus.FULFILLED, ); @@ -317,20 +323,10 @@ export const OrderNormalDetails = ({ t.id === params.id)?.authorizedAmount ?? { - amount: 0, - currency: "USD", - } - } - chargedAmount={ - order?.transactions?.find(t => t.id === params.id)?.chargedAmount ?? { - amount: 0, - currency: "USD", - } - } - orderBalance={order?.totalBalance ?? { amount: 0, currency: "USD" }} + orderTotal={order?.total.gross ?? defaultZeroMoney} + authorizedAmount={selectedTransaction?.authorizedAmount ?? defaultZeroMoney} + chargedAmount={selectedTransaction?.chargedAmount ?? defaultZeroMoney} + orderBalance={order?.totalBalance ?? defaultZeroMoney} isTransaction open={true} onClose={closeModal} @@ -400,8 +396,8 @@ export const OrderNormalDetails = ({ From 0c7a82b14fdbe8037fc9f78fe15a700c3ef4e6a9 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 26 Dec 2025 15:53:32 +0100 Subject: [PATCH 69/86] Apply code review --- src/orders/views/OrderDetails/OrderNormalDetails/index.tsx | 1 + src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 2ffdd51a85b..7818aefd612 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -321,6 +321,7 @@ export const OrderNormalDetails = ({ {/* Transaction Capture Dialog - for CHARGE action */} {params.action === "transaction-charge-action" && ( Date: Fri, 26 Dec 2025 19:48:24 +0100 Subject: [PATCH 70/86] Improve comma handling --- src/components/PriceField/utils.test.ts | 18 +++++++++++++++++ src/components/PriceField/utils.ts | 27 +++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts index 3fde43f2ffd..1b8f42638de 100644 --- a/src/components/PriceField/utils.test.ts +++ b/src/components/PriceField/utils.test.ts @@ -21,6 +21,24 @@ describe("normalizeDecimalSeparator", () => { it("handles empty string", () => { expect(normalizeDecimalSeparator("")).toBe(""); }); + + it("handles European format with thousand separator (dot) and decimal comma", () => { + // 1.234,56 = one thousand two hundred thirty-four and 56 cents + expect(normalizeDecimalSeparator("1.234,56")).toBe("1234.56"); + }); + + it("handles US format with thousand separator (comma) and decimal dot", () => { + // 1,234.56 = one thousand two hundred thirty-four and 56 cents + expect(normalizeDecimalSeparator("1,234.56")).toBe("1234.56"); + }); + + it("handles large European format numbers", () => { + expect(normalizeDecimalSeparator("1.234.567,89")).toBe("1234567.89"); + }); + + it("handles large US format numbers", () => { + expect(normalizeDecimalSeparator("1,234,567.89")).toBe("1234567.89"); + }); }); describe("parseDecimalValue", () => { diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index bcd41c3597a..64d55786940 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -35,9 +35,32 @@ export const findPriceSeparator = (input: string) => /** * Normalizes decimal separator to JavaScript standard (dot). - * Converts comma to dot for locales that use comma as decimal separator. + * Handles different locale formats: + * - European: "1.234,56" → "1234.56" (comma decimal, dot thousand) + * - US: "1,234.56" → "1234.56" (dot decimal, comma thousand) + * - Simple: "10,50" or "10.50" → "10.50" */ -export const normalizeDecimalSeparator = (value: string): string => value.replace(",", "."); +export const normalizeDecimalSeparator = (value: string): string => { + const hasComma = value.includes(","); + const hasDot = value.includes("."); + + if (hasComma && hasDot) { + // Both separators present - last one is decimal, other is thousand + const lastComma = value.lastIndexOf(","); + const lastDot = value.lastIndexOf("."); + + if (lastComma > lastDot) { + // European format: 1.234,56 → remove dots, convert comma to dot + return value.replace(/\./g, "").replace(",", "."); + } else { + // US format: 1,234.56 → remove commas + return value.replace(/,/g, ""); + } + } + + // Only comma (European decimal) or only dot (US decimal) or no separator + return value.replace(",", "."); +}; /** * Parses a decimal string value to a number, handling locale-specific separators. From b91e24bcb477bd485cc0a616a506c94f51d04407 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 26 Dec 2025 19:56:39 +0100 Subject: [PATCH 71/86] Apply code review --- .../components/OrderCaptureDialog/OrderCaptureDialog.tsx | 3 --- src/orders/components/OrderCaptureDialog/messages.ts | 5 ----- src/orders/views/OrderDetails/OrderNormalDetails/index.tsx | 1 - .../views/OrderDetails/OrderUnconfirmedDetails/index.tsx | 1 - 4 files changed, 10 deletions(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index bed32dd629f..a8c505b1db0 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -47,8 +47,6 @@ export interface OrderCaptureDialogProps { * When provided, used instead of (orderTotal - chargedAmount) for remaining calculation. */ orderBalance?: IMoney; - /** When true, shows "Transaction authorized" instead of "Authorized" */ - isTransaction?: boolean; /** Server errors from the capture mutation (supports both Legacy and Transactions API errors) */ errors?: CaptureError[]; onClose: () => void; @@ -62,7 +60,6 @@ export const OrderCaptureDialog = ({ authorizedAmount, chargedAmount, orderBalance, - isTransaction: _isTransaction = false, errors = [], onClose, onSubmit, diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts index 2a3b21831b4..fa040a87e9f 100644 --- a/src/orders/components/OrderCaptureDialog/messages.ts +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -36,11 +36,6 @@ export const messages = defineMessages({ defaultMessage: "Authorized", description: "label for authorized amount", }, - transactionAuthorized: { - id: "H0eCbU", - defaultMessage: "Transaction authorized", - description: "label for transaction authorized amount", - }, capturedSoFar: { id: "0YOedO", defaultMessage: "Captured so far", diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 7818aefd612..ecd764c8ae0 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -328,7 +328,6 @@ export const OrderNormalDetails = ({ authorizedAmount={selectedTransaction?.authorizedAmount ?? defaultZeroMoney} chargedAmount={selectedTransaction?.chargedAmount ?? defaultZeroMoney} orderBalance={order?.totalBalance ?? defaultZeroMoney} - isTransaction open={true} onClose={closeModal} onSubmit={amount => diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index 24df258248c..171f7b5cf4a 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -390,7 +390,6 @@ export const OrderUnconfirmedDetails = ({ authorizedAmount={selectedTransaction?.authorizedAmount ?? defaultZeroMoney} chargedAmount={selectedTransaction?.chargedAmount ?? defaultZeroMoney} orderBalance={order?.totalBalance ?? defaultZeroMoney} - isTransaction open={true} onClose={closeModal} onSubmit={amount => From 3d7c9600e910c6c1d545e6b9defebcd46ddb09bf Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 26 Dec 2025 19:58:53 +0100 Subject: [PATCH 72/86] Extract messages --- locale/defaultMessages.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index c392efcc991..1d9d4291440 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -3061,10 +3061,6 @@ "context": "dialog content", "string": "You are not able to modify this group members. Solve this problem to continue with request." }, - "H0eCbU": { - "context": "label for transaction authorized amount", - "string": "Transaction authorized" - }, "H1L1cc": { "context": "url", "string": "URL" From afd9086136656fe5e36e681bf7e7b2d9516f15f4 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Fri, 26 Dec 2025 20:03:27 +0100 Subject: [PATCH 73/86] Improve changeset message --- .changeset/mighty-toes-march.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/mighty-toes-march.md b/.changeset/mighty-toes-march.md index 4ddcd95b7c3..307aa5ce3c7 100644 --- a/.changeset/mighty-toes-march.md +++ b/.changeset/mighty-toes-march.md @@ -2,4 +2,8 @@ "saleor-dashboard": patch --- -Introduce new, unified order capture dialog +New capture dialog for capturing payments with support for: +- Full and partial authorization status indicators +- Custom capture amount input with currency-aware decimal validation +- Order balance and transaction-level capture tracking +- Outcome prediction showing resulting order status \ No newline at end of file From 2f5a61cd8aff8c916d9c197bc614fcee5dc9e604 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 12 Jan 2026 14:33:14 +0100 Subject: [PATCH 74/86] Use Number to maintain the amount state instead of string --- .../OrderCaptureDialog/OrderCaptureDialog.tsx | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index a8c505b1db0..2734ebcef1e 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -116,28 +116,36 @@ export const OrderCaptureDialog = ({ return isFirstOptionDisabled ? "custom" : "orderTotal"; }; - const getDefaultCustomAmount = (): string => { + const getDefaultCustomAmount = (): number => { if (authStatus === "none" || authStatus === "charged") { - return "0"; + return 0; } if (authStatus === "partial") { // Default to max capturable (remaining auth) - return String(availableToCapture); + return availableToCapture; } // Default to remaining amount to pay - return String(remainingToPay); + return remainingToPay; }; const [selectedOption, setSelectedOption] = useState(getDefaultOption); - const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + const [customAmount, setCustomAmount] = useState(getDefaultCustomAmount); + // String representation for the input field (allows user to type intermediate values like "10.") + const [customAmountInput, setCustomAmountInput] = useState( + String(getDefaultCustomAmount()), + ); // Reset state when dialog opens to ensure correct defaults based on current props useEffect(() => { if (open) { setSelectedOption(getDefaultOption()); - setCustomAmount(getDefaultCustomAmount()); + + const defaultAmount = getDefaultCustomAmount(); + + setCustomAmount(defaultAmount); + setCustomAmountInput(String(defaultAmount)); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); @@ -148,7 +156,8 @@ export const OrderCaptureDialog = ({ const handleCustomAmountChange = (e: ChangeEvent): void => { const limitedValue = limitDecimalPlaces(e.target.value, maxDecimalPlaces); - setCustomAmount(limitedValue); + setCustomAmountInput(limitedValue); + setCustomAmount(parseDecimalValue(limitedValue)); }; const getSelectedAmount = (): number => { @@ -157,13 +166,12 @@ export const OrderCaptureDialog = ({ // For partial auth, capture max available; for full, capture remaining balance return authStatus === "partial" ? availableToCapture : remainingToPay; case "custom": - return parseDecimalValue(customAmount); + return customAmount; } }; const selectedAmount = getSelectedAmount(); - const customAmountValue = parseDecimalValue(customAmount); - const isCustomAmountInRange = customAmountValue > 0 && customAmountValue <= maxCapturable; + const isCustomAmountInRange = customAmount > 0 && customAmount <= maxCapturable; const isCustomAmountValid = selectedOption !== "custom" || isCustomAmountInRange; const showCustomAmountError = selectedOption === "custom" && @@ -449,7 +457,7 @@ export const OrderCaptureDialog = ({ size="small" type="text" inputMode="decimal" - value={customAmount} + value={customAmountInput} onChange={handleCustomAmountChange} error={showCustomAmountError} disabled={ From 19848b9437a63537193169c1fab6ff861c71ae58 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 12 Jan 2026 14:36:37 +0100 Subject: [PATCH 75/86] Return ReactNode insrtead of JSX.Element --- .../components/OrderSummary/LegacyPaymentsApiButtons.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx index 1cee81838c1..ddd3575dba7 100644 --- a/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx +++ b/src/orders/components/OrderSummary/LegacyPaymentsApiButtons.tsx @@ -1,5 +1,6 @@ import { OrderDetailsFragment, OrderStatus } from "@dashboard/graphql"; import { Box, Button } from "@saleor/macaw-ui-next"; +import { ReactNode } from "react"; import { useIntl } from "react-intl"; import { transactionActionMessages } from "../OrderTransaction/messages"; @@ -26,7 +27,7 @@ export const LegacyPaymentsApiButtons = ({ onLegacyPaymentsApiCapture, onLegacyPaymentsApiRefund, onLegacyPaymentsApiVoid, -}: Props): JSX.Element | null => { +}: Props): ReactNode => { const intl = useIntl(); const showButtons = From c5b78ca6c0e271b3219fc95d8d5f692e0c055f30 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Mon, 12 Jan 2026 14:44:21 +0100 Subject: [PATCH 76/86] No silent failures If `order` or `selectedTransaction` is missing, the dialog won't render (instead of showing incorrect $0 USD) --- .../OrderDetails/OrderNormalDetails/index.tsx | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index ecd764c8ae0..38694ca081d 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -175,7 +175,6 @@ export const OrderNormalDetails = ({ const errors = orderUpdate.opts.data?.orderUpdate.errors || []; - const defaultZeroMoney = { amount: 0, currency: "USD" }; const selectedTransaction = useMemo( () => order?.transactions?.find(t => t.id === params.id), [order?.transactions, params.id], @@ -319,15 +318,15 @@ export const OrderNormalDetails = ({ } /> {/* Transaction Capture Dialog - for CHARGE action */} - {params.action === "transaction-charge-action" && ( + {params.action === "transaction-charge-action" && order && selectedTransaction && ( @@ -393,20 +392,22 @@ export const OrderNormalDetails = ({ onClose={closeModal} onConfirm={() => orderVoid.mutate({ id })} /> - - orderPaymentCapture.mutate({ - amount, - id, - }) - } - /> + {params.action === "capture" && order && ( + + orderPaymentCapture.mutate({ + amount, + id, + }) + } + /> + )} Date: Mon, 12 Jan 2026 10:47:16 +0100 Subject: [PATCH 77/86] Link to the variant's metadata instead of letting users change it in order line metadata (#6226) * Instead of letting users change variant's metadata in order line metadata dialog, we make it read-only and provide a link to the variant edit page * Add changelog * Extract messages * Fix tests * Apply code review * Improve changelog --- .changeset/calm-paws-dig.md | 9 +++++ locale/defaultMessages.json | 12 ++++-- src/components/Metadata/MetadataCard.tsx | 9 ++++- .../OrderLineMetadataDialog.test.tsx | 8 ++-- .../OrderLineMetadataDialog.tsx | 37 +++++++++++-------- 5 files changed, 50 insertions(+), 25 deletions(-) create mode 100644 .changeset/calm-paws-dig.md diff --git a/.changeset/calm-paws-dig.md b/.changeset/calm-paws-dig.md new file mode 100644 index 00000000000..76ba067f050 --- /dev/null +++ b/.changeset/calm-paws-dig.md @@ -0,0 +1,9 @@ +--- +"saleor-dashboard": patch +--- + +Prevent accidental variant metadata edits from order context + +Previously, users could edit product variant metadata directly from the Order Line Metadata dialog. This could be misleading because variant metadata is shared across all orders—changes made here would affect the variant globally, not just this order. + +Variant metadata is now displayed as read-only in the order context, with a direct link to the variant page for intentional edits. \ No newline at end of file diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 1d9d4291440..800a6f07de9 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -351,6 +351,10 @@ "context": "date group header", "string": "Last 7 days" }, + "00d8GP": { + "context": "info about variant metadata with link to edit", + "string": "The read-only metadata of the actual variant used in this order. {link}" + }, "01+5kQ": { "string": "Mark as paid" }, @@ -9230,6 +9234,10 @@ "context": "order total amount", "string": "Total" }, + "tf+OkY": { + "context": "link to edit variant metadata", + "string": "Edit on variant page" + }, "tiY7bx": { "string": "Add new product" }, @@ -9254,10 +9262,6 @@ "context": "button", "string": "Set end date" }, - "tquei9": { - "context": "modal subheader, editable product variant metadata", - "string": "This is a metadata of the variant that is being used in this ordered item" - }, "tsL3IW": { "context": "gift card history message", "string": "Gift card was sent to customer" diff --git a/src/components/Metadata/MetadataCard.tsx b/src/components/Metadata/MetadataCard.tsx index f4a5aca183f..cea009da6ad 100644 --- a/src/components/Metadata/MetadataCard.tsx +++ b/src/components/Metadata/MetadataCard.tsx @@ -16,8 +16,11 @@ export interface MetadataCardProps { readonly?: boolean; disabled?: boolean; error?: string | undefined; + defaultExpanded?: boolean; } +const ACCORDION_VALUE = "metadata-accordion"; + export const MetadataCard = ({ data, isPrivate, @@ -25,15 +28,17 @@ export const MetadataCard = ({ readonly = false, disabled, error, + defaultExpanded, }: MetadataCardProps) => { const intl = useIntl(); - const [expanded, setExpanded] = useState(readonly ? "metadata-accordion" : undefined); + const initiallyExpanded = defaultExpanded ?? false; + const [expanded, setExpanded] = useState(initiallyExpanded ? ACCORDION_VALUE : undefined); return ( - + diff --git a/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.test.tsx b/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.test.tsx index a619ec3a010..c780dfe2c10 100644 --- a/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.test.tsx +++ b/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.test.tsx @@ -203,16 +203,16 @@ describe("OrderLineMetadataDialog", () => { fireEvent.click(expandButtons[0]); - // Assert - variant metadata fields should be disabled + // Assert - variant metadata fields should be readonly const variantKeyInput = within(productVariantMetadata).getByDisplayValue( "variant-key", ) as HTMLInputElement; const variantValueInput = within(productVariantMetadata).getByDisplayValue( "variant-value", - ) as HTMLInputElement; + ) as HTMLTextAreaElement; - expect(variantKeyInput.disabled).toBe(true); - expect(variantValueInput.disabled).toBe(true); + expect(variantKeyInput.readOnly).toBe(true); + expect(variantValueInput.readOnly).toBe(true); }); }); diff --git a/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.tsx b/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.tsx index 4abb6f089b5..7acc8664372 100644 --- a/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.tsx +++ b/src/orders/components/OrderLineMetadataDialog/OrderLineMetadataDialog.tsx @@ -7,11 +7,13 @@ import { DashboardModal } from "@dashboard/components/Modal"; import { OrderLinesMetadataQuery } from "@dashboard/graphql"; import { buttonMessages } from "@dashboard/intl"; import { useHasManageProductsPermission } from "@dashboard/orders/hooks/useHasManageProductsPermission"; +import { productVariantEditUrl } from "@dashboard/products/urls"; import { mapMetadataItemToInput } from "@dashboard/utils/maps"; import { Box, Button, Divider, Text } from "@saleor/macaw-ui-next"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { FormattedMessage } from "react-intl"; +import { Link } from "react-router-dom"; import { OrderLineDetails } from "./OrderLineDetails/OrderLineDetails"; import { TEST_ID_ORDER_LINE_METADATA, TEST_ID_PRODUCT_VARIANT_METADATA } from "./test-ids"; @@ -77,8 +79,6 @@ export const OrderLineMetadataDialog = ({ handleVariantPrivateMetadataChange, orderLineMetadataErrors, orderLinePrivateMetadataErrors, - variantMetadataErrors, - variantPrivateMetadataErrors, } = useOrderLineMetadataFormControls({ control, trigger, getValues, formState }); const [showExitDialog, setShowExitDialog] = useState(false); @@ -192,9 +192,22 @@ export const OrderLineMetadataDialog = ({ + + + + + ) : null, + }} /> @@ -209,24 +222,18 @@ export const OrderLineMetadataDialog = ({ {hasManageProducts && ( )} From e1ad2b904e59fda77c665fd07cd3bf17f1bfb234 Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Mon, 12 Jan 2026 12:39:37 +0100 Subject: [PATCH 78/86] Fixed dependencies with security warnings (#6245) * Fixed dependencies with security warnings * Add changeset --------- Co-authored-by: Lukasz Ostrowski --- .changeset/some-cooks-post.md | 5 +++++ package.json | 3 ++- pnpm-lock.yaml | 21 +++++++++++---------- 3 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 .changeset/some-cooks-post.md diff --git a/.changeset/some-cooks-post.md b/.changeset/some-cooks-post.md new file mode 100644 index 00000000000..6133d69abdc --- /dev/null +++ b/.changeset/some-cooks-post.md @@ -0,0 +1,5 @@ +--- +"saleor-dashboard": patch +--- + +Updated `qs`, and patched `posthog` to use packages without security warnings diff --git a/package.json b/package.json index 97631d52d4b..744a53cf247 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "moment": "2.30.1", "moment-timezone": "^0.5.48", "posthog-js": "^1.293.0", - "qs": "^6.14.0", + "qs": "^6.14.1", "react": "18.3.1", "react-dom": "18.3.1", "react-dropzone": "^11.7.1", @@ -261,6 +261,7 @@ "glob@>=10.3.7 <=11.0.3": ">=11.1.0", "js-yaml@>=4.0.0 <4.1.1": ">=4.1.1", "js-yaml@<3.14.2": ">=3.14.2", + "preact@>=10.28.0 <10.28.2": ">=10.28.2", "tmp@<0.2.4": ">=0.2.4", "vite@>=6.0.0 <6.4.1": ">=6.4.1", "ws@>=8.0.0 <8.17.1": ">=8.17.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40c0d21cf8e..f39f03402e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,7 @@ overrides: glob@>=10.3.7 <=11.0.3: ">=11.1.0" js-yaml@>=4.0.0 <4.1.1: ">=4.1.1" js-yaml@<3.14.2: ">=3.14.2" + preact@>=10.28.0 <10.28.2: ">=10.28.2" tmp@<0.2.4: ">=0.2.4" vite@>=6.0.0 <6.4.1: ">=6.4.1" ws@>=8.0.0 <8.17.1: ">=8.17.1" @@ -186,8 +187,8 @@ importers: specifier: ^1.293.0 version: 1.301.1 qs: - specifier: ^6.14.0 - version: 6.14.0 + specifier: ^6.14.1 + version: 6.14.1 react: specifier: 18.3.1 version: 18.3.1 @@ -10775,10 +10776,10 @@ packages: integrity: sha512-ikkW716dfO1RkLREq5nrVDmcfQTrQfk3sAcP0ExuvSJ9NWOsvA2hz75bgV3GcHqG9xYlSm7Qe6Fzbn5kzx6MiQ==, } - preact@10.28.0: + preact@10.28.2: resolution: { - integrity: sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==, + integrity: sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==, } prelude-ls@1.2.1: @@ -10936,10 +10937,10 @@ packages: } engines: { node: ">=16.0.0" } - qs@6.14.0: + qs@6.14.1: resolution: { - integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==, + integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==, } engines: { node: ">=0.6" } @@ -15613,7 +15614,7 @@ snapshots: "@pollyjs/utils@6.0.6": dependencies: - qs: 6.14.0 + qs: 6.14.1 url-parse: 1.5.10 optional: true @@ -20957,10 +20958,10 @@ snapshots: "@posthog/core": 1.7.0 core-js: 3.47.0 fflate: 0.4.8 - preact: 10.28.0 + preact: 10.28.2 web-vitals: 4.2.4 - preact@10.28.0: {} + preact@10.28.2: {} prelude-ls@1.2.1: {} @@ -21063,7 +21064,7 @@ snapshots: pvutils@1.1.5: {} - qs@6.14.0: + qs@6.14.1: dependencies: side-channel: 1.1.0 From 23ab07b9a4b4e5a3eab6194123350e5a895224b3 Mon Sep 17 00:00:00 2001 From: Mirek Mencel Date: Tue, 13 Jan 2026 19:16:42 +0100 Subject: [PATCH 79/86] Improve the separator nomalisation to handle multiple tousend blocks --- src/components/PriceField/utils.test.ts | 10 ++++++++++ src/components/PriceField/utils.ts | 20 ++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/components/PriceField/utils.test.ts b/src/components/PriceField/utils.test.ts index 1b8f42638de..3d4676938ee 100644 --- a/src/components/PriceField/utils.test.ts +++ b/src/components/PriceField/utils.test.ts @@ -39,6 +39,16 @@ describe("normalizeDecimalSeparator", () => { it("handles large US format numbers", () => { expect(normalizeDecimalSeparator("1,234,567.89")).toBe("1234567.89"); }); + + it("handles US thousands-only format without decimal", () => { + // 1,234,567 = one million two hundred thirty-four thousand five hundred sixty-seven + expect(normalizeDecimalSeparator("1,234,567")).toBe("1234567"); + }); + + it("handles European thousands-only format without decimal", () => { + // 1.234.567 = one million two hundred thirty-four thousand five hundred sixty-seven + expect(normalizeDecimalSeparator("1.234.567")).toBe("1234567"); + }); }); describe("parseDecimalValue", () => { diff --git a/src/components/PriceField/utils.ts b/src/components/PriceField/utils.ts index 64d55786940..ca684d12b40 100644 --- a/src/components/PriceField/utils.ts +++ b/src/components/PriceField/utils.ts @@ -39,12 +39,14 @@ export const findPriceSeparator = (input: string) => * - European: "1.234,56" → "1234.56" (comma decimal, dot thousand) * - US: "1,234.56" → "1234.56" (dot decimal, comma thousand) * - Simple: "10,50" or "10.50" → "10.50" + * - US thousands only: "1,234,567" → "1234567" + * - European thousands only: "1.234.567" → "1234567" */ export const normalizeDecimalSeparator = (value: string): string => { - const hasComma = value.includes(","); - const hasDot = value.includes("."); + const commaCount = (value.match(/,/g) || []).length; + const dotCount = (value.match(/\./g) || []).length; - if (hasComma && hasDot) { + if (commaCount > 0 && dotCount > 0) { // Both separators present - last one is decimal, other is thousand const lastComma = value.lastIndexOf(","); const lastDot = value.lastIndexOf("."); @@ -58,7 +60,17 @@ export const normalizeDecimalSeparator = (value: string): string => { } } - // Only comma (European decimal) or only dot (US decimal) or no separator + // Multiple commas = US thousands separators (e.g., "1,234,567") + if (commaCount > 1) { + return value.replace(/,/g, ""); + } + + // Multiple dots = European thousands separators (e.g., "1.234.567") + if (dotCount > 1) { + return value.replace(/\./g, ""); + } + + // Single comma (European decimal) or single dot (US decimal) or no separator return value.replace(",", "."); }; From f8eaa71af6efecc099dc49034d82e37bdf08aebd Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 14:41:21 +0100 Subject: [PATCH 80/86] Refactor capture modal state into separate hook --- .../OrderCaptureDialog.test.tsx | 9 - .../OrderCaptureDialog/OrderCaptureDialog.tsx | 80 ++--- .../useCaptureState.test.ts | 304 ++++++++++++++++++ .../OrderCaptureDialog/useCaptureState.ts | 81 +++++ .../OrderDetails/OrderNormalDetails/index.tsx | 2 - .../OrderUnconfirmedDetails/index.tsx | 30 +- 6 files changed, 422 insertions(+), 84 deletions(-) create mode 100644 src/orders/components/OrderCaptureDialog/useCaptureState.test.ts create mode 100644 src/orders/components/OrderCaptureDialog/useCaptureState.ts diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx index fecaa540693..d23a64dc127 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -12,7 +12,6 @@ const createMoney = (amount: number, currency = "USD"): IMoney => ({ }); const defaultProps: OrderCaptureDialogProps = { - open: true, confirmButtonState: "default" as ConfirmButtonTransitionState, orderTotal: createMoney(100), authorizedAmount: createMoney(100), @@ -42,14 +41,6 @@ describe("OrderCaptureDialog", () => { expect(screen.getByText("Capture Payment")).toBeInTheDocument(); }); - it("does not render the dialog when open is false", () => { - // Arrange & Act - renderDialog({ open: false }); - - // Assert - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - }); - it("displays order total label", () => { // Arrange & Act renderDialog({ orderTotal: createMoney(150) }); diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx index 2734ebcef1e..6ed1e7bb957 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.tsx @@ -16,10 +16,11 @@ import { getOrderTransactionErrorMessage } from "@dashboard/utils/errors/transac import { IMoney } from "@dashboard/utils/intl"; import { Box, Input, RadioGroup, Text } from "@saleor/macaw-ui-next"; import { AlertTriangle, Box as BoxIcon, CheckCircle2, CircleAlert, CreditCard } from "lucide-react"; -import { ChangeEvent, useEffect, useMemo, useState } from "react"; +import { ChangeEvent, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { messages } from "./messages"; +import { AuthorizationStatus, useCaptureState } from "./useCaptureState"; type CaptureError = OrderErrorFragment | TransactionRequestActionErrorFragment; @@ -32,10 +33,7 @@ const isTransactionError = ( export type CaptureAmountOption = "orderTotal" | "custom"; -type AuthorizationStatus = "full" | "partial" | "none" | "charged"; - export interface OrderCaptureDialogProps { - open: boolean; confirmButtonState: ConfirmButtonTransitionState; orderTotal: IMoney; authorizedAmount: IMoney; @@ -54,7 +52,6 @@ export interface OrderCaptureDialogProps { } export const OrderCaptureDialog = ({ - open, confirmButtonState, orderTotal, authorizedAmount, @@ -66,48 +63,28 @@ export const OrderCaptureDialog = ({ }: OrderCaptureDialogProps): JSX.Element => { const intl = useIntl(); - const totalAmount = orderTotal.amount; - const authAmount = authorizedAmount.amount; // Available to capture (bucket model) - const alreadyCharged = chargedAmount?.amount ?? 0; const currency = orderTotal.currency; - // With bucket model: authorizedAmount = what's available to capture - // (funds move from authorizedAmount to chargedAmount when captured) - const availableToCapture = authAmount; - - // Calculate what customer still owes: - // - If orderBalance provided (multi-transaction): use it (negative balance = owes money) - // - Otherwise: simple calculation from order total minus charged - const remainingToPay = orderBalance - ? Math.max(0, -orderBalance.amount) // Convert negative balance to positive amount owed - : totalAmount - alreadyCharged; - - // Order-wide captured amount (for display in Order section) - // = order total minus what's still owed - const orderTotalCaptured = totalAmount - remainingToPay; - - // Determine authorization status - const getAuthorizationStatus = (): AuthorizationStatus => { - // Check if fully charged first (nothing left to pay) - if (remainingToPay <= 0) { - return "charged"; - } - - if (availableToCapture <= 0) { - return "none"; - } - - if (availableToCapture >= remainingToPay) { - return "full"; - } - - return "partial"; - }; + // Use custom hook for capture state calculations + const captureState = useCaptureState({ + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, + }); + + const { + availableToCapture, + alreadyCharged, + remainingToPay, + status: authStatus, + maxCapturable, + shortfall, + canCaptureOrderTotal, + orderTotalCaptured, + } = captureState; - const authStatus = getAuthorizationStatus(); - const maxCapturable = Math.max(0, availableToCapture); - const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; - const shortfall = remainingToPay - availableToCapture; + const totalAmount = orderTotal.amount; // Default selection: always prefer "orderTotal" unless it's disabled const isFirstOptionDisabled = authStatus === "none" || authStatus === "charged"; @@ -137,19 +114,6 @@ export const OrderCaptureDialog = ({ String(getDefaultCustomAmount()), ); - // Reset state when dialog opens to ensure correct defaults based on current props - useEffect(() => { - if (open) { - setSelectedOption(getDefaultOption()); - - const defaultAmount = getDefaultCustomAmount(); - - setCustomAmount(defaultAmount); - setCustomAmountInput(String(defaultAmount)); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - // Get max decimal places for this currency (e.g., 2 for USD, 0 for JPY, 3 for KWD) const maxDecimalPlaces = useMemo(() => getCurrencyDecimalPoints(currency), [currency]); @@ -259,7 +223,7 @@ export const OrderCaptureDialog = ({ const authorizedAmountColor = authStatusColorMap[authStatus]; return ( - + diff --git a/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts b/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts new file mode 100644 index 00000000000..8f27b5dab22 --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/useCaptureState.test.ts @@ -0,0 +1,304 @@ +import { IMoney } from "@dashboard/utils/intl"; +import { renderHook } from "@testing-library/react-hooks"; + +import { CaptureStateInput, useCaptureState } from "./useCaptureState"; + +const createMoney = (amount: number, currency = "USD"): IMoney => ({ + amount, + currency, +}); + +describe("useCaptureState", () => { + describe("basic scenarios", () => { + it("should calculate state for fully authorized order with no charges", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 100, + alreadyCharged: 0, + remainingToPay: 100, + status: "full", + maxCapturable: 100, + shortfall: 0, + canCaptureOrderTotal: true, + orderTotalCaptured: 0, + }); + }); + + it("should calculate state for partially authorized order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(60), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 60, + alreadyCharged: 0, + remainingToPay: 100, + status: "partial", + maxCapturable: 60, + shortfall: 40, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + + it("should calculate state for order with no authorization", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 0, + remainingToPay: 100, + status: "none", + maxCapturable: 0, + shortfall: 100, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + }); + + describe("with charged amounts", () => { + it("should calculate state for partially charged order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(80), + chargedAmount: createMoney(20), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 80, + alreadyCharged: 20, + remainingToPay: 80, + status: "full", + maxCapturable: 80, + shortfall: 0, + canCaptureOrderTotal: true, + orderTotalCaptured: 20, + }); + }); + + it("should calculate state for fully charged order", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(0), + chargedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 100, + remainingToPay: 0, + status: "charged", + maxCapturable: 0, + shortfall: 0, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + + it("should calculate state when charged amount equals order total", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(100), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 50, + alreadyCharged: 100, + remainingToPay: 0, + status: "charged", + maxCapturable: 50, + shortfall: -50, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + }); + + describe("with order balance (multi-transaction)", () => { + it("should use order balance when provided (negative balance = customer owes)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(40), + chargedAmount: createMoney(20), + orderBalance: createMoney(-30), // Customer owes 30 + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 40, + alreadyCharged: 20, + remainingToPay: 30, // From orderBalance + status: "full", + maxCapturable: 40, + shortfall: -10, + canCaptureOrderTotal: true, + orderTotalCaptured: 70, // 100 - 30 + }); + }); + + it("should handle zero order balance (fully paid)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(50), + chargedAmount: createMoney(80), + orderBalance: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 50, + alreadyCharged: 80, + remainingToPay: 0, + status: "charged", + maxCapturable: 50, + shortfall: -50, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + + it("should handle positive order balance (overpaid)", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(20), + chargedAmount: createMoney(120), + orderBalance: createMoney(20), // Overpaid by 20 + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 20, + alreadyCharged: 120, + remainingToPay: 0, // Math.max(0, -20) = 0 + status: "charged", + maxCapturable: 20, + shortfall: -20, + canCaptureOrderTotal: false, + orderTotalCaptured: 100, + }); + }); + }); + + describe("edge cases", () => { + it("should handle zero order total", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(0), + authorizedAmount: createMoney(0), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 0, + alreadyCharged: 0, + remainingToPay: 0, + status: "charged", + maxCapturable: 0, + shortfall: 0, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + + it("should handle over-authorization", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(150), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: 150, + alreadyCharged: 0, + remainingToPay: 100, + status: "full", + maxCapturable: 150, + shortfall: -50, + canCaptureOrderTotal: true, + orderTotalCaptured: 0, + }); + }); + + it("should handle negative authorized amount", () => { + // Arrange + const input: CaptureStateInput = { + orderTotal: createMoney(100), + authorizedAmount: createMoney(-10), + }; + + // Act + const { result } = renderHook(() => useCaptureState(input)); + + // Assert + expect(result.current).toEqual({ + availableToCapture: -10, + alreadyCharged: 0, + remainingToPay: 100, + status: "none", + maxCapturable: 0, // Math.max(0, -10) + shortfall: 110, + canCaptureOrderTotal: false, + orderTotalCaptured: 0, + }); + }); + }); +}); diff --git a/src/orders/components/OrderCaptureDialog/useCaptureState.ts b/src/orders/components/OrderCaptureDialog/useCaptureState.ts new file mode 100644 index 00000000000..3566b2009dd --- /dev/null +++ b/src/orders/components/OrderCaptureDialog/useCaptureState.ts @@ -0,0 +1,81 @@ +import { IMoney } from "@dashboard/utils/intl"; +import { useMemo } from "react"; + +export type AuthorizationStatus = "full" | "partial" | "none" | "charged"; + +export interface CaptureStateInput { + orderTotal: IMoney; + authorizedAmount: IMoney; + chargedAmount?: IMoney; + orderBalance?: IMoney; +} + +export interface CaptureState { + availableToCapture: number; + alreadyCharged: number; + remainingToPay: number; + status: AuthorizationStatus; + maxCapturable: number; + shortfall: number; + canCaptureOrderTotal: boolean; + orderTotalCaptured: number; +} + +export const useCaptureState = ({ + orderTotal, + authorizedAmount, + chargedAmount, + orderBalance, +}: CaptureStateInput): CaptureState => { + return useMemo(() => { + const totalAmount = orderTotal.amount; + const authAmount = authorizedAmount.amount; + const alreadyCharged = chargedAmount?.amount ?? 0; + + // With bucket model: authorizedAmount = what's available to capture + const availableToCapture = authAmount; + + // Calculate what customer still owes: + // - If orderBalance provided (multi-transaction): use it (negative balance = owes money) + // - Otherwise: simple calculation from order total minus charged + const remainingToPay = orderBalance + ? Math.max(0, -orderBalance.amount) + : totalAmount - alreadyCharged; + + // Order-wide captured amount (for display in Order section) + const orderTotalCaptured = totalAmount - remainingToPay; + + // Determine authorization status + const getAuthorizationStatus = (): AuthorizationStatus => { + if (remainingToPay <= 0) { + return "charged"; + } + + if (availableToCapture <= 0) { + return "none"; + } + + if (availableToCapture >= remainingToPay) { + return "full"; + } + + return "partial"; + }; + + const status = getAuthorizationStatus(); + const maxCapturable = Math.max(0, availableToCapture); + const shortfall = remainingToPay - availableToCapture; + const canCaptureOrderTotal = availableToCapture >= remainingToPay && remainingToPay > 0; + + return { + availableToCapture, + alreadyCharged, + remainingToPay, + status, + maxCapturable, + shortfall, + canCaptureOrderTotal, + orderTotalCaptured, + }; + }, [orderTotal, authorizedAmount, chargedAmount, orderBalance]); +}; diff --git a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx index 38694ca081d..e7d7d26989b 100644 --- a/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderNormalDetails/index.tsx @@ -327,7 +327,6 @@ export const OrderNormalDetails = ({ authorizedAmount={selectedTransaction.authorizedAmount} chargedAmount={selectedTransaction.chargedAmount} orderBalance={order.totalBalance} - open={true} onClose={closeModal} onSubmit={amount => orderTransactionAction @@ -398,7 +397,6 @@ export const OrderNormalDetails = ({ errors={orderPaymentCapture.opts.data?.orderCapture?.errors ?? []} orderTotal={order.total.gross} authorizedAmount={order.totalAuthorized} - open={true} onClose={closeModal} onSubmit={amount => orderPaymentCapture.mutate({ diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index 171f7b5cf4a..d79e09dcd56 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -390,7 +390,6 @@ export const OrderUnconfirmedDetails = ({ authorizedAmount={selectedTransaction?.authorizedAmount ?? defaultZeroMoney} chargedAmount={selectedTransaction?.chargedAmount ?? defaultZeroMoney} orderBalance={order?.totalBalance ?? defaultZeroMoney} - open={true} onClose={closeModal} onSubmit={amount => orderTransactionAction @@ -436,20 +435,21 @@ export const OrderUnconfirmedDetails = ({ onClose={closeModal} onConfirm={() => orderVoid.mutate({ id })} /> - - orderPaymentCapture.mutate({ - amount, - id, - }) - } - /> + {params.action === "capture" && ( + + orderPaymentCapture.mutate({ + amount, + id, + }) + } + /> + )} Date: Wed, 14 Jan 2026 15:05:51 +0100 Subject: [PATCH 81/86] Use order currency as default currency --- .../views/OrderDetails/OrderUnconfirmedDetails/index.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx index d79e09dcd56..987aeaf457b 100644 --- a/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx +++ b/src/orders/views/OrderDetails/OrderUnconfirmedDetails/index.tsx @@ -169,7 +169,10 @@ export const OrderUnconfirmedDetails = ({ const intl = useIntl(); const [transactionReference, setTransactionReference] = useState(""); const errors = orderUpdate.opts.data?.orderUpdate.errors || []; - const defaultZeroMoney = { amount: 0, currency: "USD" }; + const defaultZeroMoney = { + amount: 0, + currency: order?.total?.gross?.currency ?? order?.totalBalance?.currency ?? "USD", + }; const selectedTransaction = useMemo( () => order?.transactions?.find(t => t.id === params.id), [order?.transactions, params.id], From 66fb2b78feeb9ce357929893438705e03beb4040 Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 15:27:05 +0100 Subject: [PATCH 82/86] Mock theme instead of using ThemeProvider --- .../AddCustomExtension.test.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx index 7a2a0b79487..885f3bc4cda 100644 --- a/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx +++ b/src/extensions/views/AddCustomExtension/AddCustomExtension.test.tsx @@ -1,4 +1,3 @@ -import { ThemeProvider } from "@saleor/macaw-ui-next"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as React from "react"; @@ -9,10 +8,6 @@ import { usePermissions } from "./hooks/usePermissions"; import { useUserAppCreationPermissions } from "./hooks/useUserAppCreationPermissions"; import { useUserPermissionSet } from "./hooks/useUserPermissionMap"; -const Wrapper = ({ children }: React.PropsWithChildren<{}>) => ( - {children} -); - // Mock ResizeObserver used by Radix checkbox class ResizeObserverMock { observe() { @@ -30,6 +25,11 @@ class ResizeObserverMock { global.ResizeObserver = ResizeObserverMock; +jest.mock("@saleor/macaw-ui-next", () => ({ + ...(jest.requireActual("@saleor/macaw-ui-next") as object), + useTheme: () => ({ theme: "default" }), +})); + jest.mock("react-router-dom", () => ({ Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => ( @@ -66,7 +66,7 @@ describe("AddCustomExtension", () => { it("renders the component with all required elements", () => { // Arrange - render(, { wrapper: Wrapper }); + render(); // Assert expect(screen.getByPlaceholderText("Extension Name")).toBeInTheDocument(); @@ -78,7 +78,7 @@ describe("AddCustomExtension", () => { it("displays validation error when submitting empty form", async () => { // Arrange - render(, { wrapper: Wrapper }); + render(); // Act await userEvent.click(screen.getByText("save")); @@ -89,7 +89,7 @@ describe("AddCustomExtension", () => { it("creates app without permissions", async () => { // Arrange - render(, { wrapper: Wrapper }); + render(); const appNameInput = screen.getByPlaceholderText("Extension Name"); @@ -112,7 +112,7 @@ describe("AddCustomExtension", () => { it("creates app with some permissions when checked by user", async () => { // Arrange - render(, { wrapper: Wrapper }); + render(); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); @@ -140,7 +140,7 @@ describe("AddCustomExtension", () => { it("creates app with all permissions when toggled 'Grant full access'", async () => { // Arrange - render(, { wrapper: Wrapper }); + render(); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -171,7 +171,7 @@ describe("AddCustomExtension", () => { it("creates app with no permissions when toggling between 'Grant full access'", async () => { // Arrange - render(, { wrapper: Wrapper }); + render(); const appNameInput = screen.getByPlaceholderText("Extension Name"); const fullAccessCheckbox = screen.getByRole("checkbox", { @@ -216,7 +216,7 @@ describe("AddCustomExtension", () => { (useUserAppCreationPermissions as jest.Mock).mockReturnValue(true); // Act - render(, { wrapper: Wrapper }); + render(); // Assert expect(screen.getByText(/warning/i)).toBeInTheDocument(); @@ -230,7 +230,7 @@ describe("AddCustomExtension", () => { const availablePermissions = new Set(["MANAGE_ORDERS"]); (useUserPermissionSet as jest.Mock).mockReturnValue(availablePermissions); - render(, { wrapper: Wrapper }); + render(); const appNameInput = screen.getByPlaceholderText("Extension Name"); const ordersCheckbox = screen.getByLabelText(/Manage Orders/i); From f562ee81ed9365bd70e3b4908d0634aa1b6dc69d Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 15:27:20 +0100 Subject: [PATCH 83/86] CR: Minor fixes --- src/auth/utils.ts | 4 ++-- src/components/Callout/messages.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/auth/utils.ts b/src/auth/utils.ts index 19efcb42f03..61bf6cb9584 100644 --- a/src/auth/utils.ts +++ b/src/auth/utils.ts @@ -1,5 +1,5 @@ import { ApolloError, ServerError } from "@apollo/client/core"; -import { IMessage, IMessageContext } from "@dashboard/components/messages"; +import { IMessageContext } from "@dashboard/components/messages"; import { commonMessages } from "@dashboard/intl"; import { getMutationErrors, parseLogMessage } from "@dashboard/misc"; import { getAppMountUriForRedirect } from "@dashboard/utils/urls"; @@ -60,7 +60,7 @@ export const handleNestedMutationErrors = ({ }: { data: any; intl: IntlShape; - notify: (message: IMessage) => void; + notify: IMessageContext; }) => { const mutationErrors = getMutationErrors({ data }); diff --git a/src/components/Callout/messages.ts b/src/components/Callout/messages.ts index 012b5169465..029f5fce83d 100644 --- a/src/components/Callout/messages.ts +++ b/src/components/Callout/messages.ts @@ -1,5 +1,7 @@ import { defineMessages } from "react-intl"; +// Re-export common messages for backward compatibility +// TODO: Remove and use `commonMessages` from @dashboard/intl export const calloutTitleMessages = defineMessages({ info: { defaultMessage: "Info", From e43abf27703b18f3204ee46989d2bef734c20fb7 Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 15:30:32 +0100 Subject: [PATCH 84/86] Apply suggestion from @witoszekdev --- src/orders/components/OrderCaptureDialog/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts index fa040a87e9f..579eb9a57b3 100644 --- a/src/orders/components/OrderCaptureDialog/messages.ts +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -13,7 +13,7 @@ export const messages = defineMessages({ }, statusPartial: { id: "ZUYQ+C", - defaultMessage: "Partial authorisation", + defaultMessage: "Partial Authorisation", description: "status pill for partial authorization", }, statusNoAuthorization: { From 324d8426cd23120ab4521f33035bbc8f50fdbc8d Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 15:36:45 +0100 Subject: [PATCH 85/86] Extract messages --- locale/defaultMessages.json | 8 ++++---- src/orders/components/OrderCaptureDialog/messages.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/locale/defaultMessages.json b/locale/defaultMessages.json index 3d3a5d3c1cf..bf709926e1e 100644 --- a/locale/defaultMessages.json +++ b/locale/defaultMessages.json @@ -6144,10 +6144,6 @@ "context": "empty state message", "string": "No refunds made for this order." }, - "ZUYQ+C": { - "context": "status pill for partial authorization", - "string": "Partial authorisation" - }, "ZXOpCJ": { "string": "An unexpected issue occurred when parsing manifest. Please contact support. ({errorCode})" }, @@ -6397,6 +6393,10 @@ "context": "range input label", "string": "Postal codes (end)" }, + "ayylzh": { + "context": "status pill for partial authorization", + "string": "Partial Authorisation" + }, "b+jcaN": { "string": "There are still fulfillments created for this order. Cancel the fulfillments first before you cancel the order." }, diff --git a/src/orders/components/OrderCaptureDialog/messages.ts b/src/orders/components/OrderCaptureDialog/messages.ts index 579eb9a57b3..dc836db648a 100644 --- a/src/orders/components/OrderCaptureDialog/messages.ts +++ b/src/orders/components/OrderCaptureDialog/messages.ts @@ -12,7 +12,7 @@ export const messages = defineMessages({ description: "status pill for fully authorized payment", }, statusPartial: { - id: "ZUYQ+C", + id: "ayylzh", defaultMessage: "Partial Authorisation", description: "status pill for partial authorization", }, From 8a1cdd39a364422a031d60b5c1f238af61c39a3e Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Wed, 14 Jan 2026 15:47:45 +0100 Subject: [PATCH 86/86] Fix test --- .../components/OrderCaptureDialog/OrderCaptureDialog.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx index d23a64dc127..ddaa980f565 100644 --- a/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx +++ b/src/orders/components/OrderCaptureDialog/OrderCaptureDialog.test.tsx @@ -70,7 +70,7 @@ describe("OrderCaptureDialog", () => { expect(screen.getByText("Fully Authorized")).toBeInTheDocument(); }); - it("shows 'Partial authorisation' pill when authorized < remaining", () => { + it("shows 'Partial Authorisation' pill when authorized < remaining", () => { // Arrange & Act renderDialog({ orderTotal: createMoney(100), @@ -78,7 +78,7 @@ describe("OrderCaptureDialog", () => { }); // Assert - expect(screen.getByText("Partial authorisation")).toBeInTheDocument(); + expect(screen.getByText("Partial Authorisation")).toBeInTheDocument(); }); it("shows warning callout for partial authorization with shortfall", () => {