@thorprovider/adapters 5.0.8 → 5.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,9 @@
1
+ ---
2
+ title: "Multi-Tenant Channel Scoping"
3
+ purpose: "Backend-agnostic pattern for isolating customers, orders, and categories per sales channel using metadata"
4
+ lastReviewed: "2026-09-28"
5
+ ---
6
+
1
7
  # Multi-Tenant Channel Scoping
2
8
 
3
9
  > Backend-agnostic pattern for isolating entities by sales channel using metadata.
@@ -147,10 +153,42 @@ The pattern is generic. To scope a new entity type:
147
153
  |-----------------|------------------------|------------------------|
148
154
  | Products | ✅ `sales_channel_id` | N/A (native) |
149
155
  | Carts | ✅ `sales_channel_id` | N/A (native) |
150
- | Categories | ❌ | ⚠️ Via product cross-ref |
151
- | **Customers** | ❌ | ✅ `metadata.sales_channel_id` |
152
- | **Orders** | ❌ | ✅ `metadata.sales_channel_id` |
153
- | Collections | ❌ (linked via products) | Planned |
156
+ | Categories | ✅ via `sales_channel` link (`/store/thor/categories`) | N/A (native) |
157
+ | **Customers** | ⚠️ native `customer_sales_channel` link in backend | ✅ `metadata.sales_channel_id` (adapter filter) |
158
+ | **Orders** | ✅ inherits `sales_channel_id` from cart | ✅ `metadata.sales_channel_id` (adapter filter) |
159
+ | Collections | ✅ via `sales_channel` link (`/store/thor/collections`) | N/A (native) |
160
+
161
+ ## Thor Commerce Plugin (Medusa) — Native Publishable-Key Scoping
162
+
163
+ When the backend runs the Thor Commerce plugin (see `medusa-backend`, `src/api/store/thor/*`),
164
+ scoping is resolved **server-side from the publishable API key** — no `sales_channel_id`
165
+ query param is ever required from the storefront:
166
+
167
+ - **Resolution:** Medusa core middleware maps `x-publishable-api-key` →
168
+ `sales_channel_ids`. A key bound to exactly one channel auto-injects that channel
169
+ into `/store/carts` bodies; a key bound to multiple channels requires the caller to
170
+ pass `sales_channel_id` explicitly.
171
+ - **Products / carts / orders:** natively scoped by `sales_channel_id` (orders inherit
172
+ the cart's channel; a checkout workflow hook validates every line against the cart channel).
173
+ - **Categories / collections:** scoped via their `sales_channel` M:M links. The adapter's
174
+ `getCategories` / `getCollections` call `GET /store/thor/categories` /
175
+ `GET /store/thor/collections` **unconditionally** — the endpoint resolves the channel
176
+ from the publishable key. The `salesChannelId` option (or
177
+ `NEXT_PUBLIC_SALES_CHANNEL_ID`) is only used for the legacy N+1 fallback when the
178
+ plugin endpoint is absent.
179
+ - **Payment methods:** there is no channel concept for payment in Medusa. `getPaymentMethods`
180
+ is scoped by **region** (+ currency) only.
181
+ - **createCart:** passes `sales_channel_id` into the payload when
182
+ `options.salesChannelId` or `NEXT_PUBLIC_SALES_CHANNEL_ID` is set (belt-and-suspenders —
183
+ the publishable key alone already scopes single-channel carts).
184
+ - **Customers:** the backend auto-links customers to the channel (middleware on
185
+ `/store/carts*` and `/store/customers/me*`). The adapter still filters
186
+ `getCustomers` via `metadata.sales_channel_id`; for backends with the plugin, prefer
187
+ `GET /store/thor/customers` for fully server-scoped reads.
188
+
189
+ > Note: backend-verified at Medusa v2.18.0 (`medusa-backend` repo). The plugin's
190
+ > `/store/thor/*` routes accept only `x-publishable-api-key`; they return `400` if the
191
+ > key resolves to no channel.
154
192
 
155
193
  ## When to Assign Channel Scope
156
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thorprovider/adapters",
3
- "version": "5.0.8",
3
+ "version": "5.0.9",
4
4
  "description": "Framework-agnostic commerce adapters for Thor Commerce ecosystem - Multi-platform support with unified API",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "@medusajs/js-sdk": "2.16.0",
31
31
  "@medusajs/types": "2.16.0",
32
- "@thorprovider/types": "^5.3.2",
32
+ "@thorprovider/types": "^5.6.3",
33
33
  "bcryptjs": "^3.0.3",
34
34
  "jose": "^6.2.2"
35
35
  },
@@ -220,6 +220,46 @@ describe('MedusaProvider — Cart', () => {
220
220
  expect(cart!.id).toBe('cart_01')
221
221
  })
222
222
 
223
+ it('getCart transforms flat v2 cart items (no nested variant.product)', async () => {
224
+ const flatCart = {
225
+ ...MEDUSA_CART,
226
+ items: [
227
+ {
228
+ id: 'cali_01',
229
+ variant_id: 'variant_01',
230
+ product_id: 'prod_01',
231
+ product_title: 'Cover Samsung Note 10 Plus',
232
+ product_handle: 'cover-samsung-note-10-plus',
233
+ variant_title: 'Cover Samsung Note 10 Plus - Negro',
234
+ title: 'Cover Samsung Note 10 Plus',
235
+ thumbnail: 'https://img.example.com/cover.png',
236
+ quantity: 2,
237
+ unit_price: 60,
238
+ },
239
+ ],
240
+ total: 120,
241
+ subtotal: 120,
242
+ totalQuantity: 2,
243
+ }
244
+ server.use(
245
+ http.get(`${BASE}/store/carts/cart_01`, () =>
246
+ HttpResponse.json({ cart: flatCart }),
247
+ ),
248
+ )
249
+
250
+ const provider = makeProvider()
251
+ const cart = await provider.getCart('cart_01')
252
+ const line = cart?.lines?.[0]
253
+
254
+ expect(line?.merchandise.id).toBe('variant_01')
255
+ expect(line?.merchandise.title).toBe('Cover Samsung Note 10 Plus - Negro')
256
+ expect(line?.merchandise.product.id).toBe('prod_01')
257
+ expect(line?.merchandise.product.handle).toBe('cover-samsung-note-10-plus')
258
+ expect(line?.merchandise.product.title).toBe('Cover Samsung Note 10 Plus')
259
+ expect(line?.merchandise.product.featuredImage.url).toBe('https://img.example.com/cover.png')
260
+ expect(line?.cost.totalAmount.amount).toBe('120.00')
261
+ })
262
+
223
263
  it('getCart returns undefined when cart not found (404)', async () => {
224
264
  server.use(
225
265
  http.get(`${BASE}/store/carts/cart_missing`, () =>
@@ -153,6 +153,7 @@ export class MedusaProvider implements CommerceProvider {
153
153
  private retryConfig: Required<RetryConfig>;
154
154
  private baseUrl: string;
155
155
  private publishableKey: string;
156
+ private regionId?: string;
156
157
 
157
158
  /** Secret API key for admin operations (if configured). */
158
159
  private adminApiKey?: string;
@@ -210,6 +211,7 @@ export class MedusaProvider implements CommerceProvider {
210
211
  this.currencyCode = config.currencyCode ?? 'USD';
211
212
  this.baseUrl = config.baseUrl;
212
213
  this.publishableKey = config.publishableKey;
214
+ this.regionId = config.regionId;
213
215
 
214
216
  // Store retry configuration
215
217
  this.retryConfig = {
@@ -389,10 +391,11 @@ export class MedusaProvider implements CommerceProvider {
389
391
  try {
390
392
  return await this.withRetry(
391
393
  async () => {
394
+ const regionId = options?.regionId ?? this.regionId;
392
395
  // calculated_price requires region_id in Medusa v2 — only request it when available
393
396
  const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
394
397
  const pricingFields = ',+variants.calculated_price,+variants.prices';
395
- const fields = options?.regionId ? baseFields + pricingFields : baseFields;
398
+ const fields = regionId ? baseFields + pricingFields : baseFields;
396
399
 
397
400
  const queryParams: any = {
398
401
  handle,
@@ -400,8 +403,8 @@ export class MedusaProvider implements CommerceProvider {
400
403
  fields,
401
404
  };
402
405
  // Add region_id for region-based pricing
403
- if (options?.regionId) {
404
- queryParams.region_id = options.regionId;
406
+ if (regionId) {
407
+ queryParams.region_id = regionId;
405
408
  }
406
409
 
407
410
  // Scope to storefront sales channel when configured.
@@ -439,10 +442,11 @@ export class MedusaProvider implements CommerceProvider {
439
442
  try {
440
443
  return await this.withRetry(
441
444
  async () => {
445
+ const regionId = options.regionId ?? this.regionId;
442
446
  // calculated_price requires region_id in Medusa v2 — only request it when available
443
447
  const baseFields = '+variants,+variants.inventory_quantity,+options,+images,+tags';
444
448
  const pricingFields = ',+variants.calculated_price,+variants.prices';
445
- const fields = options.regionId ? baseFields + pricingFields : baseFields;
449
+ const fields = regionId ? baseFields + pricingFields : baseFields;
446
450
 
447
451
  // Map Thor SortOption values to Medusa /store/products `order` field names.
448
452
  // Native endpoint supports: title, -title, created_at, -created_at
@@ -473,8 +477,8 @@ export class MedusaProvider implements CommerceProvider {
473
477
  }
474
478
 
475
479
  // Add region_id for region-based pricing
476
- if (options.regionId) {
477
- queryParams.region_id = options.regionId;
480
+ if (regionId) {
481
+ queryParams.region_id = regionId;
478
482
  }
479
483
 
480
484
  // Basic category/collection filtering
@@ -987,6 +991,18 @@ export class MedusaProvider implements CommerceProvider {
987
991
 
988
992
  let result = collections.map(transformCollection);
989
993
 
994
+ // --- Sales channel scoping ---
995
+ // Applied unconditionally: the Thor Commerce plugin endpoint resolves the
996
+ // sales channel from the publishable key itself. An explicit channel id
997
+ // (options.salesChannelId ?? env) is only needed for the legacy N+1
998
+ // fallback on backends without the plugin.
999
+ const salesChannelId =
1000
+ options?.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1001
+ const activeCollectionIds = await this._getChannelCollections(salesChannelId);
1002
+ if (activeCollectionIds) {
1003
+ result = result.filter((c: Collection) => activeCollectionIds.has(c.id));
1004
+ }
1005
+
990
1006
  // Client-side search filtering (Medusa Store API lacks query param for collections)
991
1007
  if (options?.query) {
992
1008
  const q = options.query.toLowerCase();
@@ -1007,6 +1023,100 @@ export class MedusaProvider implements CommerceProvider {
1007
1023
  }
1008
1024
  }
1009
1025
 
1026
+ /**
1027
+ * Returns the set of product-collection IDs that have at least one product
1028
+ * available in the given sales channel (legacy fallback).
1029
+ *
1030
+ * @deprecated Use `_getChannelCollections()` instead (backed by Thor Commerce plugin).
1031
+ */
1032
+ private async _getActiveCollectionIds(salesChannelId: string): Promise<Set<string>> {
1033
+ this.logger.debug('_getActiveCollectionIds', { salesChannelId });
1034
+ const collectionIds = new Set<string>();
1035
+ const limit = 500;
1036
+ let offset = 0;
1037
+
1038
+ // eslint-disable-next-line no-constant-condition
1039
+ while (true) {
1040
+ const { products } = (await this.sdk.store.product.list({
1041
+ sales_channel_id: [salesChannelId],
1042
+ fields: '+collections.id',
1043
+ limit,
1044
+ offset,
1045
+ } as any)) as any;
1046
+
1047
+ for (const product of products ?? []) {
1048
+ for (const col of product.collections ?? []) {
1049
+ if (col?.id) collectionIds.add(col.id);
1050
+ }
1051
+ }
1052
+
1053
+ if (!products || products.length < limit) break;
1054
+ offset += limit;
1055
+ }
1056
+
1057
+ this.logger.debug('_getActiveCollectionIds result', { count: collectionIds.size });
1058
+ return collectionIds;
1059
+ }
1060
+
1061
+ /**
1062
+ * Fetch channel-scoped collections from Thor Commerce plugin.
1063
+ * Uses `GET /store/thor/collections` — the endpoint resolves the sales
1064
+ * channel from the publishable key header itself.
1065
+ *
1066
+ * Falls back to `_getActiveCollectionIds()` (legacy N+1) when the plugin is
1067
+ * not available; the legacy fallback needs an explicit `salesChannelId`.
1068
+ * Returns `null` when no scoping could be performed.
1069
+ */
1070
+ private async _getChannelCollections(salesChannelId?: string): Promise<Set<string> | null> {
1071
+ try {
1072
+ const url = `${this.baseUrl}/store/thor/collections`;
1073
+ const response = await fetchWithRetry(
1074
+ url,
1075
+ {
1076
+ method: 'GET',
1077
+ headers: {
1078
+ 'x-publishable-api-key': this.publishableKey,
1079
+ 'Content-Type': 'application/json',
1080
+ },
1081
+ },
1082
+ this.retryConfig,
1083
+ );
1084
+
1085
+ if (response.status === 404 || !response.ok) {
1086
+ this.logger.debug('Thor plugin collections endpoint unavailable, falling back to legacy method');
1087
+ return this._legacyChannelCollections(salesChannelId);
1088
+ }
1089
+
1090
+ const data = await response.json();
1091
+ const collectionIds = new Set<string>();
1092
+ for (const col of data.collections ?? []) {
1093
+ if (col?.id) collectionIds.add(col.id);
1094
+ }
1095
+
1096
+ this.logger.debug('_getChannelCollections result (Thor plugin)', { count: collectionIds.size });
1097
+ return collectionIds;
1098
+ } catch (error) {
1099
+ this.logger.warn('Failed to fetch channel collections from Thor plugin, falling back to legacy method', {
1100
+ error: error instanceof Error ? error.message : String(error),
1101
+ });
1102
+ return this._legacyChannelCollections(salesChannelId);
1103
+ }
1104
+ }
1105
+
1106
+ /**
1107
+ * Legacy fallback for `_getChannelCollections()` when the Thor plugin is not
1108
+ * installed. Requires an explicit `salesChannelId`; returns `null` otherwise.
1109
+ */
1110
+ private async _legacyChannelCollections(salesChannelId?: string): Promise<Set<string> | null> {
1111
+ if (!salesChannelId) {
1112
+ this.logger.warn(
1113
+ 'Cannot scope collections: Thor plugin unavailable and no sales channel id provided; returning unscoped collections',
1114
+ );
1115
+ return null;
1116
+ }
1117
+ return this._getActiveCollectionIds(salesChannelId);
1118
+ }
1119
+
1010
1120
  async getCollectionProducts(
1011
1121
  handle: string,
1012
1122
  options: CollectionProductsOptions = {},
@@ -1125,10 +1235,20 @@ export class MedusaProvider implements CommerceProvider {
1125
1235
 
1126
1236
  /**
1127
1237
  * Fetch channel-scoped categories from Thor Commerce plugin.
1128
- * Uses `GET /store/thor/categories` — a single indexed query.
1129
- * Falls back to `_getActiveCategoryIds()` if the plugin is not available.
1238
+ * Uses `GET /store/thor/categories` — a single indexed query. The endpoint
1239
+ * resolves the sales channel from the publishable key header itself, so no
1240
+ * explicit channel id is required when the plugin is installed.
1241
+ *
1242
+ * Falls back to `_getActiveCategoryIds()` (legacy N+1) when the plugin is
1243
+ * not available. The legacy fallback needs an explicit `salesChannelId`;
1244
+ * when none is provided and the plugin is unavailable, returns `null` to
1245
+ * signal that scoping is not possible and the caller should skip filtering.
1246
+ *
1247
+ * @param salesChannelId - Only used by the legacy fallback (plugin absent).
1248
+ * @returns Set of category IDs active in the channel, or `null` when no
1249
+ * scoping could be performed.
1130
1250
  */
1131
- private async _getChannelCategories(salesChannelId: string): Promise<Set<string>> {
1251
+ private async _getChannelCategories(salesChannelId?: string): Promise<Set<string> | null> {
1132
1252
  try {
1133
1253
  const url = `${this.baseUrl}/store/thor/categories`;
1134
1254
  const response = await fetchWithRetry(
@@ -1143,17 +1263,10 @@ export class MedusaProvider implements CommerceProvider {
1143
1263
  this.retryConfig,
1144
1264
  );
1145
1265
 
1146
- if (response.status === 404) {
1266
+ if (response.status === 404 || !response.ok) {
1147
1267
  // Plugin not installed — fall back to legacy N+1 method
1148
- this.logger.debug('Thor plugin categories endpoint not found, falling back to legacy method');
1149
- return this._getActiveCategoryIds(salesChannelId);
1150
- }
1151
-
1152
- if (!response.ok) {
1153
- this.logger.warn('Thor plugin categories endpoint returned non-OK status', {
1154
- status: response.status,
1155
- });
1156
- return this._getActiveCategoryIds(salesChannelId);
1268
+ this.logger.debug('Thor plugin categories endpoint unavailable, falling back to legacy method');
1269
+ return this._legacyChannelCategories(salesChannelId);
1157
1270
  }
1158
1271
 
1159
1272
  const data = await response.json();
@@ -1168,10 +1281,24 @@ export class MedusaProvider implements CommerceProvider {
1168
1281
  this.logger.warn('Failed to fetch channel categories from Thor plugin, falling back to legacy method', {
1169
1282
  error: error instanceof Error ? error.message : String(error),
1170
1283
  });
1171
- return this._getActiveCategoryIds(salesChannelId);
1284
+ return this._legacyChannelCategories(salesChannelId);
1172
1285
  }
1173
1286
  }
1174
1287
 
1288
+ /**
1289
+ * Legacy fallback for `_getChannelCategories()` when the Thor plugin is not
1290
+ * installed. Requires an explicit `salesChannelId`; returns `null` otherwise.
1291
+ */
1292
+ private async _legacyChannelCategories(salesChannelId?: string): Promise<Set<string> | null> {
1293
+ if (!salesChannelId) {
1294
+ this.logger.warn(
1295
+ 'Cannot scope categories: Thor plugin unavailable and no sales channel id provided; returning unscoped categories',
1296
+ );
1297
+ return null;
1298
+ }
1299
+ return this._getActiveCategoryIds(salesChannelId);
1300
+ }
1301
+
1175
1302
  async getCategories(options: GetCategoriesOptions = {}): Promise<ProductCategory[]> {
1176
1303
  this.logger.debug('getCategories', { options });
1177
1304
 
@@ -1202,12 +1329,14 @@ export class MedusaProvider implements CommerceProvider {
1202
1329
  let result = product_categories.map(transformCategory);
1203
1330
 
1204
1331
  // --- Sales channel scoping ---
1205
- // Prefer Thor Commerce plugin endpoint (single indexed query).
1206
- // Falls back to legacy N+1 method if plugin is not installed.
1332
+ // Applied unconditionally: the Thor Commerce plugin endpoint resolves the
1333
+ // sales channel from the publishable key itself. An explicit channel id
1334
+ // (options.salesChannelId ?? env) is only needed for the legacy N+1
1335
+ // fallback on backends without the plugin.
1207
1336
  const salesChannelId =
1208
1337
  options.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1209
- if (salesChannelId) {
1210
- const activeCategoryIds = await this._getChannelCategories(salesChannelId);
1338
+ const activeCategoryIds = await this._getChannelCategories(salesChannelId);
1339
+ if (activeCategoryIds) {
1211
1340
  result = result.filter((c: ProductCategory) => activeCategoryIds.has(c.id));
1212
1341
  }
1213
1342
 
@@ -1254,6 +1383,11 @@ export class MedusaProvider implements CommerceProvider {
1254
1383
  '+variants,+variants.prices,+variants.calculated_prices,+variants.inventory_quantity,+options,+images,+tags',
1255
1384
  };
1256
1385
 
1386
+ // Region-based pricing (calculated_price requires region_id in Medusa v2)
1387
+ if (this.regionId) {
1388
+ categoryQueryParams.region_id = this.regionId;
1389
+ }
1390
+
1257
1391
  // Scope to storefront sales channel when configured.
1258
1392
  const salesChannelId = process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1259
1393
  if (salesChannelId) {
@@ -1279,8 +1413,11 @@ export class MedusaProvider implements CommerceProvider {
1279
1413
  // Cart Operations
1280
1414
  // ============================================
1281
1415
 
1282
- async createCart(options?: { regionId?: string }): Promise<Cart> {
1283
- this.logger.debug('createCart', { regionId: options?.regionId });
1416
+ async createCart(options?: { regionId?: string; salesChannelId?: string }): Promise<Cart> {
1417
+ this.logger.debug('createCart', {
1418
+ regionId: options?.regionId,
1419
+ salesChannelId: options?.salesChannelId,
1420
+ });
1284
1421
 
1285
1422
  try {
1286
1423
  const createPayload: any = {};
@@ -1294,6 +1431,16 @@ export class MedusaProvider implements CommerceProvider {
1294
1431
  createPayload.currency_code = this.currencyCode.toLowerCase();
1295
1432
  }
1296
1433
 
1434
+ // Explicit sales channel scope. Normally Medusa auto-injects the channel
1435
+ // from the publishable key when it maps to exactly one channel, but
1436
+ // passing it explicitly is required when the key maps to multiple
1437
+ // channels (otherwise cart creation errors).
1438
+ const salesChannelId =
1439
+ options?.salesChannelId ?? process.env.NEXT_PUBLIC_SALES_CHANNEL_ID;
1440
+ if (salesChannelId) {
1441
+ createPayload.sales_channel_id = salesChannelId;
1442
+ }
1443
+
1297
1444
  const { cart } = await this.sdk.store.cart.create(createPayload);
1298
1445
 
1299
1446
  return transformCart(cart);
@@ -1347,16 +1494,12 @@ export class MedusaProvider implements CommerceProvider {
1347
1494
  retrieveCtx,
1348
1495
  );
1349
1496
 
1350
- console.log('retrieveResult =', JSON.stringify(retrieveResult, null, 2))
1351
-
1352
1497
  const backendCart = (retrieveResult as any)?.cart;
1353
1498
  if (!backendCart) {
1354
1499
  this.logger.warn('Cart not found before addToCart', { cartId });
1355
1500
  throw new ProviderAPIError('Cart not found. Please refresh and try again.', 'medusa', 404);
1356
1501
  }
1357
1502
 
1358
- console.log('backendCart =', JSON.stringify(backendCart, null, 2))
1359
-
1360
1503
  if (!backendCart.region_id) {
1361
1504
  this.logger.warn('Attempted to add item to cart with no region_id', { cartId });
1362
1505
  throw new ProviderAPIError(
@@ -1416,8 +1559,6 @@ export class MedusaProvider implements CommerceProvider {
1416
1559
  );
1417
1560
  }
1418
1561
 
1419
- console.log('Found Variant:', JSON.stringify(foundVariant, null, 2));
1420
-
1421
1562
  const variant = foundVariant;
1422
1563
  // Prefer calculated_price when present
1423
1564
  let hasPriceForRegion = false;
@@ -1432,10 +1573,6 @@ export class MedusaProvider implements CommerceProvider {
1432
1573
  ''
1433
1574
  ).toLowerCase();
1434
1575
 
1435
- console.log('calcCurrency =', calcCurrency);
1436
- console.log('cartCurrency =', cartCurrency);
1437
- console.log('this.currencyCode =', this.currencyCode)
1438
-
1439
1576
  if (
1440
1577
  !cartCurrency ||
1441
1578
  calcCurrency === cartCurrency ||
@@ -291,7 +291,16 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
291
291
  const product = variant?.product;
292
292
  const currencyCode = resolveMedusaCartItemCurrencyCode(item, cartCurrencyCode);
293
293
  const totalCents = resolveMedusaCartItemTotal(item);
294
-
294
+
295
+ // Medusa v2 store cart items return flat fields (product_title, variant_title,
296
+ // product_handle, thumbnail) instead of a nested variant.product object.
297
+ const merchandiseId = variant?.id ?? item.variant_id ?? '';
298
+ const merchandiseTitle = variant?.title ?? item.variant_title ?? '';
299
+ const productId = product?.id ?? item.product_id ?? '';
300
+ const productHandle = product?.handle ?? item.product_handle ?? '';
301
+ const productTitle = product?.title ?? item.product_title ?? '';
302
+ const thumbnail = product?.thumbnail ?? item.thumbnail ?? '';
303
+
295
304
  return {
296
305
  id: item.id,
297
306
  quantity: item.quantity ?? 1,
@@ -299,19 +308,18 @@ export function transformCartItem(item: any, cartCurrencyCode?: string): CartIte
299
308
  totalAmount: transformMoney(totalCents, currencyCode),
300
309
  },
301
310
  merchandise: {
302
- id: variant?.id ?? '',
303
- title: variant?.title ?? '',
304
- selectedOptions: variant?.options?.map((opt: any) => ({
305
- name: (opt as any).option?.title ?? '',
306
- value: opt.value ?? '',
307
- })) ?? [],
311
+ id: merchandiseId,
312
+ title: merchandiseTitle,
313
+ selectedOptions:
314
+ variant?.options?.map((opt: any) => ({
315
+ name: (opt as any).option?.title ?? '',
316
+ value: opt.value ?? '',
317
+ })) ?? [],
308
318
  product: {
309
- id: product?.id ?? '',
310
- handle: product?.handle ?? '',
311
- title: product?.title ?? '',
312
- featuredImage: product?.thumbnail
313
- ? transformImage(product.thumbnail, product.title ?? '')
314
- : transformImage('', ''),
319
+ id: productId,
320
+ handle: productHandle,
321
+ title: productTitle,
322
+ featuredImage: thumbnail ? transformImage(thumbnail, productTitle) : transformImage('', ''),
315
323
  },
316
324
  },
317
325
  };