feat: carry over promotions toggle on exchanges (#14128)

* feat: carry over promotions toggle on exchanges

* fix: inital flag value, return the flag on preview

* fix: validation of allocation type

* fix: revert client changes

* fix: invert condition

* feat: recompute adjustments when outbound item is updated

* fix: condition again

* fix: display more accurate inbound/outbound totals for exchanges

* fix: make exchanges specs green

* feat: more testing cases

* wip: pr feedback

* fix: use plural for the flag on Admin

* fix: schema test, route refactor

* feat: tooltip

* feat: refactor to use update workflow

* feat: display applied promotion per item on order details, show copy sku on hover

* feat: refactor edits and exchanges to have common flag toggle flow

* fix: delete empty file

* fix: exchange_id param query
This commit is contained in:
Frane Polić
2025-11-30 19:31:31 +01:00
committed by GitHub
parent 9d1f09ac7b
commit 5da51064d7
40 changed files with 1367 additions and 214 deletions
@@ -10,8 +10,8 @@ import {
import { sdk } from "../../lib/client"
import { queryClient } from "../../lib/query-client"
import { queryKeysFactory, TQueryKey } from "../../lib/query-key-factory"
import { inventoryItemsQueryKeys } from "./inventory"
import { reservationItemsQueryKeys } from "./reservations"
import { inventoryItemsQueryKeys } from "./inventory"
const ORDERS_QUERY_KEY = "orders" as const
const _orderKeys = queryKeysFactory(ORDERS_QUERY_KEY) as TQueryKey<"orders"> & {
@@ -406,3 +406,35 @@ export const useCreateOrderCreditLine = (
...options,
})
}
export const useUpdateOrderChange = (
orderChangeId: string,
options?: UseMutationOptions<
HttpTypes.AdminOrderChangeResponse,
FetchError,
{ carry_over_promotions: boolean }
>
) => {
return useMutation({
mutationFn: (payload: { carry_over_promotions: boolean }) =>
sdk.admin.order.updateOrderChange(orderChangeId, payload),
onSuccess: (data, variables, context) => {
const orderId = data.order_change.order_id
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.details(),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.preview(orderId),
})
queryClient.invalidateQueries({
queryKey: ordersQueryKeys.changes(orderId),
})
options?.onSuccess?.(data, variables, context)
},
...options,
})
}
@@ -2008,6 +2008,7 @@
"uploadImagesLabel",
"uploadImagesHint",
"invalidFileType",
"fileTooLarge",
"failedToUpload",
"deleteWarning_one",
"deleteWarning_other",
@@ -4907,6 +4908,15 @@
"refundAmount": {
"type": "string"
},
"carryOverPromotion": {
"type": "string"
},
"carryOverPromotionHint": {
"type": "string"
},
"carryOverPromotionTooltip": {
"type": "string"
},
"activeChangeError": {
"type": "string"
},
@@ -4988,6 +4998,9 @@
"outboundShipping",
"outboundShippingHint",
"refundAmount",
"carryOverPromotion",
"carryOverPromotionHint",
"carryOverPromotionTooltip",
"activeChangeError",
"actions",
"cancel",
@@ -1305,6 +1305,9 @@
"outboundShipping": "Outbound shipping",
"outboundShippingHint": "Choose which method you want to use.",
"refundAmount": "Estimated difference",
"carryOverPromotion": "Carry over promotions",
"carryOverPromotionHint": "Apply the order's promotions to the exchange items",
"carryOverPromotionTooltip": "Only fixed type promotions with EACH allocation and percentage type promotions with EACH or ACROSS allocation can be carried over to outbound exchange items.",
"activeChangeError": "There is an active order change on this order. Please finish or discard the previous change.",
"actions": {
"cancelExchange": {
@@ -8,7 +8,7 @@ export const sdk = new Medusa({
baseUrl: backendUrl,
auth: {
type: authType,
jwtTokenStorageKey
jwtTokenStorageKey,
},
})
@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { PencilSquare } from "@medusajs/icons"
import { InformationCircleSolid, PencilSquare } from "@medusajs/icons"
import { AdminExchange, AdminOrder, AdminOrderPreview } from "@medusajs/types"
import {
Button,
@@ -8,6 +8,7 @@ import {
IconButton,
Switch,
toast,
Tooltip,
usePrompt,
} from "@medusajs/ui"
import { useEffect, useMemo, useState } from "react"
@@ -31,6 +32,7 @@ import {
useUpdateExchangeInboundShipping,
useUpdateExchangeOutboundShipping,
} from "../../../../../hooks/api/exchanges"
import { useUpdateOrderChange } from "../../../../../hooks/api/orders"
import { currencies } from "../../../../../lib/data/currencies"
import { ExchangeInboundSection } from "./exchange-inbound-section.tsx"
import { ExchangeOutboundSection } from "./exchange-outbound-section"
@@ -92,6 +94,15 @@ export const ExchangeCreateForm = ({
isPending: isUpdatingInboundShipping,
} = useUpdateExchangeOutboundShipping(exchange.id, order.id)
const { mutateAsync: updateOrderChange } = useUpdateOrderChange(
preview?.order_change?.id!,
{
onError: (error) => {
toast.error(error.message)
},
}
)
const isRequestLoading =
isConfirming ||
isCanceling ||
@@ -117,6 +128,14 @@ export const ExchangeCreateForm = ({
(item) => !!item.actions?.find((a) => a.action === "ITEM_ADD")
)
const hasPromotions = useMemo(() => {
return (
(order as any).promotions &&
Array.isArray((order as any).promotions) &&
(order as any).promotions.length > 0
)
}, [order])
/**
* FORM
*/
@@ -161,6 +180,8 @@ export const ExchangeCreateForm = ({
: "",
location_id: orderReturn?.location_id,
send_notification: false,
carry_over_promotions:
preview?.order_change?.carry_over_promotions ?? false,
})
},
resolver: zodResolver(ExchangeCreateSchema),
@@ -306,7 +327,14 @@ export const ExchangeCreateForm = ({
const action = item.actions?.find(
(act) => act.action === "RETURN_ITEM"
)
acc = acc + (action?.amount || 0)
/**
* TODO: update this when the change actions return amounts are revamped
* it is might not cover all the cases but is more accurate then just using `unit_price` which does't consider adjustments
*/
acc =
acc +
((action?.details.quantity || 0) / item.quantity) *
item.total
return acc
}, 0) * -1,
@@ -323,10 +351,7 @@ export const ExchangeCreateForm = ({
<span className="txt-small text-ui-fg-subtle">
{getStylizedAmount(
outboundPreviewItems.reduce((acc, item) => {
const action = item.actions?.find(
(act) => act.action === "ITEM_ADD"
)
acc = acc + (action?.amount || 0)
acc = acc + (item.total || 0) // outbound items entire quantity is used for calculating outbound total
return acc
}, 0),
@@ -498,6 +523,59 @@ export const ExchangeCreateForm = ({
</span>
</div>
</div>
{/* CARRY OVER PROMOTION */}
{hasPromotions && (
<div className="bg-ui-bg-field mt-4 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
control={form.control}
name="carry_over_promotions"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item>
<div className="flex items-center">
<Form.Control className="mr-4 self-start">
<Switch
dir="ltr"
className="mt-[2px] rtl:rotate-180"
checked={!!value}
onCheckedChange={async (checked) => {
onChange(checked)
if (preview?.order_change?.id) {
await updateOrderChange({
carry_over_promotions: checked,
})
}
}}
{...field}
/>
</Form.Control>
<div className="block">
<Form.Label className="flex items-center gap-x-2">
{t("orders.exchanges.carryOverPromotion")}
<Form.Hint>
<Tooltip
content={t(
"orders.exchanges.carryOverPromotionTooltip"
)}
>
<InformationCircleSolid />
</Tooltip>
</Form.Hint>
</Form.Label>
<Form.Hint className="!mt-1">
{t("orders.exchanges.carryOverPromotionHint")}
</Form.Hint>
</div>
</div>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
)}
{/* SEND NOTIFICATION*/}
<div className="bg-ui-bg-field mt-8 rounded-lg border py-2 pl-2 pr-4">
<Form.Field
@@ -19,6 +19,7 @@ export const ExchangeCreateSchema = z.object({
inbound_option_id: z.string().nullish(),
outbound_option_id: z.string().nullish(),
send_notification: z.boolean().optional(),
carry_over_promotions: z.boolean().optional(),
})
export type CreateExchangeSchemaType = z.infer<typeof ExchangeCreateSchema>
@@ -10,6 +10,7 @@ import {
DocumentText,
ExclamationCircle,
PencilSquare,
ReceiptPercent,
TriangleDownMini,
} from "@medusajs/icons"
import {
@@ -402,29 +403,49 @@ const Item = ({
item.variant?.inventory_items?.some((i) => i.required_quantity > 1))
const hasUnfulfilledItems = item.quantity - item.detail.fulfilled_quantity > 0
const appliedPromoCodes = (item.adjustments || []).map((a) => a.code)
return (
<>
<div
key={item.id}
className="text-ui-fg-subtle grid grid-cols-2 items-center gap-x-4 px-6 py-4"
>
<div className="flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text size="small" leading="compact" className="text-ui-fg-base">
{item.title}
</Text>
<div className=" flex justify-between gap-x-2 ">
<div className=" group flex items-start gap-x-4">
<Thumbnail src={item.thumbnail} />
<div>
<Text size="small" leading="compact" className="text-ui-fg-base">
{item.title}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy content={item.variant_sku} className="text-ui-fg-muted" />
</div>
)}
<Text size="small">
{item.variant?.options?.map((o) => o.value).join(" · ")}
</Text>
{item.variant_sku && (
<div className="flex items-center gap-x-1">
<Text size="small">{item.variant_sku}</Text>
<Copy
content={item.variant_sku}
className="text-ui-fg-muted hidden group-hover:block"
/>
</div>
)}
<Text size="small">
{item.variant?.options?.map((o) => o.value).join(" · ")}
</Text>
</div>
</div>
{appliedPromoCodes.length > 0 && (
<Tooltip
content={
<span className="text-pretty">
{appliedPromoCodes.map((code) => (
<div key={code}>{code}</div>
))}
</span>
}
>
<ReceiptPercent className="text-ui-fg-subtle flex-shrink self-center " />
</Tooltip>
)}
</div>
<div className="grid grid-cols-3 items-center gap-x-4">