feat(): sync cart translation synced (#14226)

ref: https://github.com/medusajs/medusa/pull/14189

  **Summary**

  This PR extends the translation module to support automatic translation syncing for cart line items based on the cart's locale.

  Key changes:
  - Added locale field to the Cart model to store the cart's locale preference
  - Created new workflow steps:
    - getTranslatedLineItemsStep - Translates line items when adding to cart or creating a cart
    - updateCartItemsTranslationsStep - Re-translates all cart items when the cart's locale changes
  - Integrated translation logic into cart workflows:
    - createCartWorkflow - Applies translations to initial line items
    - addToCartWorkflow - Applies translations when adding new items
    - updateCartWorkflow - Re-translates all items when locale_code is updated
    - refreshCartItemsWorkflow - Maintains translations during cart refresh
  - Added applyTranslationsToItems utility to map variant/product/type/collection translations to line item fields (title, subtitle, description, etc.)
This commit is contained in:
Adrien de Peretti
2025-12-10 09:37:30 +01:00
committed by GitHub
parent 356283c359
commit e4877616c3
31 changed files with 2635 additions and 1474 deletions

View File

@@ -0,0 +1,46 @@
import { ProductVariantDTO } from "@medusajs/framework/types"
import { applyTranslations, FeatureFlag } from "@medusajs/framework/utils"
import {
createStep,
StepFunction,
StepResponse,
} from "@medusajs/framework/workflows-sdk"
import { applyTranslationsToItems } from "../utils/apply-translations-to-items"
export interface GetTranslatedLineItemsStepInput<T> {
items: T[] | undefined
variants: Partial<ProductVariantDTO>[]
locale: string | undefined
}
export const getTranslatedLineItemsStepId = "get-translated-line-items"
const step = createStep(
getTranslatedLineItemsStepId,
async (data: GetTranslatedLineItemsStepInput<any>, { container }) => {
const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation")
if (!isTranslationEnabled || !data.locale || !data.items?.length) {
return new StepResponse(data.items ?? [])
}
await applyTranslations({
localeCode: data.locale,
objects: data.variants,
container,
})
const translatedItems = applyTranslationsToItems(data.items, data.variants)
return new StepResponse(translatedItems)
}
)
/**
* This step translates cart line items based on their associated variant and product IDs.
* It fetches translations for the product (title, description, subtitle) and variant (title),
* then applies them to the corresponding line item fields.
*/
export const getTranslatedLineItemsStep = <T>(
data: GetTranslatedLineItemsStepInput<T>
): ReturnType<StepFunction<any, T[]>> => step(data)

View File

@@ -10,6 +10,8 @@ export * from "./find-or-create-customer"
export * from "./find-sales-channel"
export * from "./get-actions-to-compute-from-promotions"
export * from "./get-line-item-actions"
export * from "./get-translated-line-items"
export * from "./update-cart-items-translations"
export * from "./get-promotion-codes-to-apply"
export * from "./get-variant-price-sets"
export * from "./get-variants"

View File

@@ -0,0 +1,199 @@
import { MedusaContainer } from "@medusajs/framework"
import {
ICartModuleService,
ProductVariantDTO,
RemoteQueryFunction,
} from "@medusajs/framework/types"
import {
applyTranslations,
ContainerRegistrationKeys,
deduplicate,
FeatureFlag,
Modules,
} from "@medusajs/framework/utils"
import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk"
import { applyTranslationsToItems } from "../utils/apply-translations-to-items"
import { productVariantsFields } from "../utils/fields"
export interface UpdateCartItemsTranslationsStepInput {
cart_id: string
locale: string
/**
* Pre-loaded items to avoid re-fetching.
*/
items?: { id: string; variant_id?: string; [key: string]: any }[]
}
const BATCH_SIZE = 100
const lineItemFields = [
"id",
"variant_id",
"product_id",
"title",
"subtitle",
"product_title",
"product_description",
"product_subtitle",
"product_type",
"product_collection",
"product_handle",
"variant_title",
]
export const updateCartItemsTranslationsStepId =
"update-cart-items-translations"
type ItemTranslationSnapshot = {
id: string
title: string
subtitle: string
product_title: string
product_description: string
product_subtitle: string
product_type: string
product_collection: string
product_handle: string
variant_title: string
}
async function compensation(
originalItems,
{ container }: { container: MedusaContainer }
) {
if (!originalItems?.length) {
return
}
const cartModule = container.resolve<ICartModuleService>(Modules.CART)
for (let i = 0; i < originalItems.length; i += BATCH_SIZE) {
const batch = originalItems.slice(i, i + BATCH_SIZE)
await cartModule.updateLineItems(batch)
}
}
/**
* This step re-translates all cart line items when the cart's locale changes.
* It fetches items and their variants in batches to handle large carts gracefully.
*/
export const updateCartItemsTranslationsStep = createStep(
updateCartItemsTranslationsStepId,
async (data: UpdateCartItemsTranslationsStepInput, { container }) => {
const originalItems: ItemTranslationSnapshot[] = []
try {
const isTranslationEnabled = FeatureFlag.isFeatureEnabled("translation")
if (!isTranslationEnabled || !data.locale) {
return new StepResponse(void 0, [])
}
const cartModule = container.resolve<ICartModuleService>(Modules.CART)
const query = container.resolve<RemoteQueryFunction>(
ContainerRegistrationKeys.QUERY
)
const processBatch = async (
items: { id: string; variant_id?: string; [key: string]: any }[]
) => {
const variantIds = deduplicate(
items
.map((item) => item.variant_id)
.filter((id): id is string => !!id)
)
if (variantIds.length === 0) {
return
}
// Store original values before updating
for (const item of items) {
originalItems.push({
id: item.id,
title: item.title,
subtitle: item.subtitle,
product_title: item.product_title,
product_description: item.product_description,
product_subtitle: item.product_subtitle,
product_type: item.product_type,
product_collection: item.product_collection,
product_handle: item.product_handle,
variant_title: item.variant_title,
})
}
const { data: variants } = await query.graph({
entity: "variants",
filters: { id: variantIds },
fields: productVariantsFields,
})
await applyTranslations({
localeCode: data.locale,
objects: variants as Record<string, any>[],
container,
})
const translatedItems = applyTranslationsToItems(
items as { variant_id?: string; [key: string]: any }[],
variants as Partial<ProductVariantDTO>[]
)
const itemsToUpdate = translatedItems
.filter((item) => item.id)
.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle,
product_title: item.product_title,
product_description: item.product_description,
product_subtitle: item.product_subtitle,
product_type: item.product_type,
product_collection: item.product_collection,
product_handle: item.product_handle,
variant_title: item.variant_title,
}))
if (itemsToUpdate.length > 0) {
await cartModule.updateLineItems(itemsToUpdate)
}
}
if (data.items?.length) {
await processBatch(data.items)
return new StepResponse(void 0, originalItems)
}
let offset = 0
let hasMore = true
while (hasMore) {
const { data: items } = await query.graph({
entity: "line_items",
filters: { cart_id: data.cart_id },
fields: lineItemFields,
pagination: {
take: BATCH_SIZE,
skip: offset,
},
})
if (items.length === 0) {
hasMore = false
break
}
await processBatch(items as { id: string; variant_id?: string }[])
offset += items.length
hasMore = items.length === BATCH_SIZE
}
return new StepResponse(void 0, originalItems)
} catch (error) {
await compensation(originalItems, { container })
throw error
}
},
compensation
)

View File

@@ -0,0 +1,69 @@
import { ProductVariantDTO } from "@medusajs/framework/types"
const VARIANT_PREFIX = "variant_"
const PRODUCT_PREFIX = "product_"
const PRODUCT_TYPE_PREFIX = "type_"
const PRODUCT_COLLECTION_PREFIX = "collection_"
const TRANSLATABLE_ITEM_PROP_PREFIXES = [
VARIANT_PREFIX,
PRODUCT_PREFIX,
PRODUCT_TYPE_PREFIX,
PRODUCT_COLLECTION_PREFIX,
]
const entityGetterPerPrefix = {
[VARIANT_PREFIX]: (variant: ProductVariantDTO) => variant,
[PRODUCT_PREFIX]: (variant: ProductVariantDTO) => variant.product!,
[PRODUCT_TYPE_PREFIX]: (variant: ProductVariantDTO) => variant.product?.type!,
[PRODUCT_COLLECTION_PREFIX]: (variant: ProductVariantDTO) =>
variant.product?.collection!,
}
function applyTranslation(
itemAny: Record<string, any>,
translatedInput: Record<string, any>,
key: string,
translationKey: string
) {
if (typeof itemAny[key] === typeof translatedInput?.[translationKey]) {
itemAny[key] = translatedInput?.[translationKey]
}
}
/**
* Applies translated variant/product fields to line items.
*/
export function applyTranslationsToItems<
T extends { variant_id?: string; [key: string]: any }
>(items: T[], variants: Partial<ProductVariantDTO>[]): T[] {
const variantMap = new Map(variants.map((variant) => [variant.id, variant]))
return items.map((item) => {
if (!item.variant_id) {
return item
}
const variant = variantMap.get(item.variant_id)
if (!variant) {
return item
}
const itemAny = item as Record<string, any>
Object.entries(itemAny).forEach(([key, value]) => {
for (const prefix of TRANSLATABLE_ITEM_PROP_PREFIXES) {
if (key.startsWith(prefix)) {
const translationKey = key.replace(prefix, "")
const entity = entityGetterPerPrefix[prefix](variant)
if (!entity) {
break
}
applyTranslation(itemAny, entity, key, translationKey)
}
}
})
return item
})
}

View File

@@ -7,6 +7,7 @@ export const cartFieldsForRefreshSteps = [
"quantity",
"subtotal",
"item_total",
"locale",
"total",
"item_subtotal",
"shipping_subtotal",

View File

@@ -139,30 +139,30 @@ export function prepareLineItemData(data: PrepareLineItemDataInput) {
let lineItem: any = {
quantity: item?.quantity,
title: variant?.product?.title ?? item?.title,
subtitle: variant?.title ?? item?.subtitle,
title: item?.title ?? variant?.product?.title,
subtitle: item?.subtitle ?? variant?.title,
thumbnail:
variant?.thumbnail ?? variant?.product?.thumbnail ?? item?.thumbnail,
item?.thumbnail ?? variant?.thumbnail ?? variant?.product?.thumbnail,
product_id: variant?.product?.id ?? item?.product_id,
product_title: variant?.product?.title ?? item?.product_title,
product_title: item?.product_title ?? variant?.product?.title,
product_description:
variant?.product?.description ?? item?.product_description,
product_subtitle: variant?.product?.subtitle ?? item?.product_subtitle,
product_type: variant?.product?.type?.value ?? item?.product_type ?? null,
item?.product_description ?? variant?.product?.description,
product_subtitle: item?.product_subtitle ?? variant?.product?.subtitle,
product_type: item?.product_type ?? variant?.product?.type?.value ?? null,
product_type_id:
variant?.product?.type?.id ?? item?.product_type_id ?? null,
item?.product_type_id ?? variant?.product?.type?.id ?? null,
product_collection:
variant?.product?.collection?.title ?? item?.product_collection ?? null,
product_handle: variant?.product?.handle ?? item?.product_handle,
item?.product_collection ?? variant?.product?.collection?.title ?? null,
product_handle: item?.product_handle ?? variant?.product?.handle,
variant_id: variant?.id,
variant_sku: variant?.sku ?? item?.variant_sku,
variant_barcode: variant?.barcode ?? item?.variant_barcode,
variant_title: variant?.title ?? item?.variant_title,
variant_sku: item?.variant_sku ?? variant?.sku,
variant_barcode: item?.variant_barcode ?? variant?.barcode,
variant_title: item?.variant_title ?? variant?.title,
variant_option_values: item?.variant_option_values,
is_discountable: variant?.product?.discountable ?? item?.is_discountable,
is_discountable: item?.is_discountable ?? variant?.product?.discountable,
is_giftcard: variant?.product?.is_giftcard ?? false,
requires_shipping: requiresShipping,

View File

@@ -23,6 +23,7 @@ import { acquireLockStep, releaseLockStep } from "../../locking"
import {
createLineItemsStep,
getLineItemActionsStep,
getTranslatedLineItemsStep,
updateLineItemsStep,
} from "../steps"
import { validateCartStep } from "../steps/validate-cart"
@@ -42,7 +43,9 @@ import { confirmVariantInventoryWorkflow } from "./confirm-variant-inventory"
import { getVariantsAndItemsWithPrices } from "./get-variants-and-items-with-prices"
import { refreshCartItemsWorkflow } from "./refresh-cart-items"
const cartFields = ["completed_at"].concat(cartFieldsForPricingContext)
const cartFields = ["completed_at", "locale"].concat(
cartFieldsForPricingContext
)
export const addToCartWorkflowId = "add-to-cart"
/**
@@ -292,10 +295,33 @@ export const addToCartWorkflow = createWorkflow(
},
})
const itemsToCreateVariants = transform(
{ itemsToCreate, variants } as {
itemsToCreate: CreateLineItemForCartDTO[]
variants: PrepareVariantLineItemInput[]
},
(data) => {
if (!data.itemsToCreate?.length) {
return []
}
const variantsMap = new Map(data.variants?.map((v) => [v.id, v]))
return data.itemsToCreate
.map((item) => item.variant_id && variantsMap.get(item.variant_id))
.filter(Boolean) as PrepareVariantLineItemInput[]
}
)
const translatedItemsToCreate = getTranslatedLineItemsStep({
items: itemsToCreate,
variants: itemsToCreateVariants,
locale: cart.locale,
})
const [createdLineItems, updatedLineItems] = parallelize(
createLineItemsStep({
id: cart.id,
items: itemsToCreate,
items: translatedItemsToCreate,
}),
updateLineItemsStep({
id: cart.id,

View File

@@ -3,6 +3,7 @@ import {
ConfirmVariantInventoryWorkflowInputDTO,
CreateCartDTO,
CreateCartWorkflowInputDTO,
CreateLineItemDTO,
} from "@medusajs/framework/types"
import {
CartWorkflowEvents,
@@ -23,6 +24,7 @@ import {
findOneOrAnyRegionStep,
findOrCreateCustomerStep,
findSalesChannelStep,
getTranslatedLineItemsStep,
} from "../steps"
import { validateSalesChannelStep } from "../steps/validate-sales-channel"
import { productVariantsFields } from "../utils/fields"
@@ -205,17 +207,31 @@ export const createCartWorkflow = createWorkflow(
}
}
return data_
return data_ as CreateCartDTO
}
)
const cartToCreate = transform({ lineItems, cartInput }, (data) => {
return {
...data.cartInput,
items: data.lineItems.map((i) => i.data),
} as unknown as CreateCartDTO
const itemsToCreate = transform({ lineItems }, (data) => {
return data.lineItems.map((i) => i.data as CreateLineItemDTO)
})
const translatedItems = getTranslatedLineItemsStep({
items: itemsToCreate,
variants,
locale: input.locale,
})
const cartToCreate = transform(
{ cartInput, translatedItems } as unknown as {
cartInput: CreateCartDTO
translatedItems: CreateLineItemDTO[]
},
(data) => {
data.cartInput.items = data.translatedItems
return data.cartInput as unknown as CreateCartDTO
}
)
const validate = createHook("validate", {
input: cartInput,
cart: cartToCreate,

View File

@@ -5,6 +5,7 @@ import {
CreateCartCreateLineItemDTO,
CustomerDTO,
OrderWorkflow,
ProductVariantDTO,
RegionDTO,
UpdateLineItemDTO,
UpdateLineItemWithSelectorDTO,
@@ -54,7 +55,7 @@ interface GetVariantsAndItemsWithPricesWorkflowInput {
type GetVariantsAndItemsWithPricesWorkflowOutput = {
// The variant can depend on the requested fields and therefore the caller will know better
variants: (object & {
variants: (Partial<ProductVariantDTO> & {
calculated_price: {
calculated_price: {
price_list_type: string
@@ -184,8 +185,11 @@ export const getVariantsAndItemsWithPrices = createWorkflow(
}
const variant = variantsData.find((v) => v.id === item.variant_id)
if ((item.variant_id && !variant) || // variant specified but doesn't exist
(variant && (!variant?.product?.status || variant.product.status !== ProductStatus.PUBLISHED)) // variant exists but product is not published
if (
(item.variant_id && !variant) || // variant specified but doesn't exist
(variant &&
(!variant?.product?.status ||
variant.product.status !== ProductStatus.PUBLISHED)) // variant exists but product is not published
) {
variantNotFoundOrPublished.push(item_.variant_id)
}
@@ -225,7 +229,9 @@ export const getVariantsAndItemsWithPrices = createWorkflow(
if (variantNotFoundOrPublished.length > 0) {
throw new MedusaError(
MedusaError.Types.INVALID_DATA,
`Variants ${variantNotFoundOrPublished.join(", ")} do not exist or belong to a product that is not published`
`Variants ${variantNotFoundOrPublished.join(
", "
)} do not exist or belong to a product that is not published`
)
}
if (priceNotFound.length > 0) {

View File

@@ -10,7 +10,11 @@ import {
} from "@medusajs/framework/workflows-sdk"
import { useQueryGraphStep } from "../../common"
import { acquireLockStep, releaseLockStep } from "../../locking"
import { updateLineItemsStep, validateCartStep } from "../steps"
import {
updateCartItemsTranslationsStep,
updateLineItemsStep,
validateCartStep,
} from "../steps"
import { cartFieldsForRefreshSteps } from "../utils/fields"
import { pricingContextResult } from "../utils/schemas"
import { getVariantsAndItemsWithPrices } from "./get-variants-and-items-with-prices"
@@ -54,6 +58,12 @@ export type RefreshCartItemsWorkflowInput = {
* on the configurations of the cart's tax region.
*/
force_tax_calculation?: boolean
/**
* The new locale code to update cart items translations.
* When provided, all cart items will be re-translated using this locale.
*/
locale?: string
}
export const refreshCartItemsWorkflowId = "refresh-cart-items"
@@ -234,6 +244,16 @@ export const refreshCartItemsWorkflow = createWorkflow(
},
})
when("should-update-item-translations", { input }, ({ input }) => {
return !!input.locale
}).then(() => {
updateCartItemsTranslationsStep({
cart_id: input.cart_id,
locale: input.locale!,
items: refetchedCart.items,
})
})
const beforeRefreshingPaymentCollection = createHook(
"beforeRefreshingPaymentCollection",
{ input }

View File

@@ -99,6 +99,7 @@ export const updateCartWorkflow = createWorkflow(
"email",
"customer_id",
"sales_channel_id",
"locale",
"shipping_address.*",
"region.*",
"region.countries.*",
@@ -280,6 +281,17 @@ export const updateCartWorkflow = createWorkflow(
}).config({ name: "emit-region-updated" })
})
// Get the new locale code if it's being updated
const newLocaleCode = transform(
{ input, cartToUpdate },
({ input, cartToUpdate }) => {
if (isDefined(input.locale) && input.locale !== cartToUpdate?.locale) {
return input.locale
}
return undefined
}
)
parallelize(
updateCartsStep([cartInput]),
emitEventStep({
@@ -314,6 +326,7 @@ export const updateCartWorkflow = createWorkflow(
cart_id: cartInput.id,
promo_codes: input.promo_codes,
force_refresh: !!newRegion,
locale: newLocaleCode,
additional_data: input.additional_data,
},
})

View File

@@ -14,7 +14,7 @@ export const createTranslationsStepId = "create-translations"
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* locale: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ])

View File

@@ -36,7 +36,7 @@ export const updateTranslationsStepId = "update-translations"
* const data = updateTranslationsStep({
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* locale: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }

View File

@@ -24,7 +24,7 @@ export const validateTranslationsStep = createStep(
} = await query.graph(
{
entity: "store",
fields: ["supported_locales.*"],
fields: ["id", "supported_locales.*"],
pagination: {
take: 1,
},

View File

@@ -28,7 +28,7 @@ export const createTranslationsWorkflowId = "create-translations"
* {
* reference_id: "prod_123",
* reference: "product",
* locale_code: "fr-FR",
* locale: "fr-FR",
* translations: { title: "Produit", description: "Description du produit" }
* }
* ]

View File

@@ -24,7 +24,7 @@ export const updateTranslationsWorkflowId = "update-translations"
* input: {
* selector: {
* reference_id: "prod_123",
* locale_code: "fr-FR"
* locale: "fr-FR"
* },
* update: {
* translations: { title: "Nouveau titre" }