@porulle/adapter-shopify 0.10.8 → 0.11.0
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.
- package/dist/index.js +110 -17
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/index.ts +141 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/adapter-shopify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
}
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@porulle/core": "0.
|
|
13
|
+
"@porulle/core": "0.11.0"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^24.5.2",
|
|
17
17
|
"eslint": "^9.39.1",
|
|
18
18
|
"typescript": "5.9.2",
|
|
19
19
|
"vitest": "^3.2.4",
|
|
20
|
-
"@porulle/
|
|
21
|
-
"@porulle/
|
|
20
|
+
"@porulle/eslint-config": "0.1.0",
|
|
21
|
+
"@porulle/typescript-config": "0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { defineChannelConnector, Err, Ok } from "@porulle/core";
|
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import type {
|
|
4
4
|
ChannelCatalogPage,
|
|
5
|
+
ChannelCatalogPrice,
|
|
5
6
|
ChannelConnector,
|
|
6
7
|
ChannelConnectorError,
|
|
7
8
|
ChannelInventoryLevel,
|
|
@@ -33,13 +34,92 @@ type ShopifyProduct = {
|
|
|
33
34
|
title: string;
|
|
34
35
|
handle?: string;
|
|
35
36
|
body_html?: string;
|
|
36
|
-
|
|
37
|
+
vendor?: string | null;
|
|
38
|
+
product_type?: string | null;
|
|
39
|
+
tags?: string | null;
|
|
40
|
+
status?: string | null;
|
|
41
|
+
images?: Array<{
|
|
42
|
+
id: number | string;
|
|
43
|
+
src: string;
|
|
44
|
+
alt?: string | null;
|
|
45
|
+
position?: number | null;
|
|
46
|
+
variant_ids?: Array<number | string> | null;
|
|
47
|
+
}>;
|
|
48
|
+
options?: Array<{
|
|
49
|
+
name: string;
|
|
50
|
+
position?: number | null;
|
|
51
|
+
values?: string[];
|
|
52
|
+
}>;
|
|
53
|
+
variants?: Array<{
|
|
54
|
+
id: number | string;
|
|
55
|
+
sku?: string | null;
|
|
56
|
+
barcode?: string | null;
|
|
57
|
+
price?: string | null;
|
|
58
|
+
compare_at_price?: string | null;
|
|
59
|
+
option1?: string | null;
|
|
60
|
+
option2?: string | null;
|
|
61
|
+
option3?: string | null;
|
|
62
|
+
}>;
|
|
37
63
|
};
|
|
38
64
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
const zeroDecimalCurrencies = new Set([
|
|
66
|
+
"BIF",
|
|
67
|
+
"CLP",
|
|
68
|
+
"DJF",
|
|
69
|
+
"GNF",
|
|
70
|
+
"ISK",
|
|
71
|
+
"JPY",
|
|
72
|
+
"KMF",
|
|
73
|
+
"KRW",
|
|
74
|
+
"PYG",
|
|
75
|
+
"RWF",
|
|
76
|
+
"UGX",
|
|
77
|
+
"VND",
|
|
78
|
+
"VUV",
|
|
79
|
+
"XAF",
|
|
80
|
+
"XOF",
|
|
81
|
+
"XPF",
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
function normalizeCurrency(value: unknown): string | undefined {
|
|
85
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
86
|
+
return value.trim().toUpperCase();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseMoney(value: string | null | undefined, currency: string): number | undefined {
|
|
90
|
+
if (value == null || value.trim() === "") return undefined;
|
|
91
|
+
const parsed = Number(value);
|
|
92
|
+
if (!Number.isFinite(parsed)) return undefined;
|
|
93
|
+
const exponent = zeroDecimalCurrencies.has(currency) ? 0 : 2;
|
|
94
|
+
return Math.round(parsed * (10 ** exponent));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pricesForVariant(
|
|
98
|
+
variant: NonNullable<ShopifyProduct["variants"]>[number],
|
|
99
|
+
currency: string | undefined,
|
|
100
|
+
): ChannelCatalogPrice[] | undefined {
|
|
101
|
+
if (!currency) return undefined;
|
|
102
|
+
const amount = parseMoney(variant.price, currency);
|
|
103
|
+
if (amount === undefined) return undefined;
|
|
104
|
+
const compareAtAmount = parseMoney(variant.compare_at_price, currency);
|
|
105
|
+
return [{
|
|
106
|
+
currency,
|
|
107
|
+
amount,
|
|
108
|
+
...(compareAtAmount !== undefined && compareAtAmount !== amount ? { compareAtAmount } : {}),
|
|
109
|
+
}];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function catalogStatus(value: string | null | undefined): "draft" | "active" | "archived" | undefined {
|
|
113
|
+
return value === "draft" || value === "active" || value === "archived" ? value : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function slugify(value: string): string {
|
|
117
|
+
return value.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchShopCurrency(fetchImpl: typeof fetch, url: string, accessToken: string): Promise<string | undefined> {
|
|
121
|
+
const result = await request<{ shop?: { currency?: string | null } }>(fetchImpl, url, accessToken);
|
|
122
|
+
return result.ok ? normalizeCurrency(result.value.data.shop?.currency) : undefined;
|
|
43
123
|
}
|
|
44
124
|
|
|
45
125
|
function apiBase(store: ChannelStore, version: string): string {
|
|
@@ -106,6 +186,7 @@ function oauthError(code: string, message: string): Result<never, ChannelConnect
|
|
|
106
186
|
export function shopifyConnector(options: ShopifyConnectorOptions = {}): ChannelConnector {
|
|
107
187
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
108
188
|
const version = options.apiVersion ?? "2024-10";
|
|
189
|
+
const currencyCache = new Map<string, Promise<string | undefined>>();
|
|
109
190
|
return defineChannelConnector({
|
|
110
191
|
providerId: "shopify",
|
|
111
192
|
capabilities: { importCatalog: true, importInventory: true, pushOrder: true, receiveWebhooks: true },
|
|
@@ -159,24 +240,67 @@ export function shopifyConnector(options: ShopifyConnectorOptions = {}): Channel
|
|
|
159
240
|
async importCatalog(store, cursor): Promise<Result<ChannelCatalogPage>> {
|
|
160
241
|
const token = credentials(store);
|
|
161
242
|
if (!token) return Err({ code: "SHOPIFY_CREDENTIALS_REQUIRED", message: "Shopify accessToken is required." });
|
|
243
|
+
const currencyUrl = `${apiBase(store, version)}/shop.json`;
|
|
244
|
+
const currencyKey = apiBase(store, version);
|
|
245
|
+
let currencyPromise = currencyCache.get(currencyKey);
|
|
246
|
+
if (!currencyPromise) {
|
|
247
|
+
currencyPromise = fetchShopCurrency(fetchImpl, currencyUrl, token);
|
|
248
|
+
currencyCache.set(currencyKey, currencyPromise);
|
|
249
|
+
}
|
|
250
|
+
const currency = await currencyPromise;
|
|
162
251
|
const url = cursor ?? `${apiBase(store, version)}/products.json?limit=250`;
|
|
163
252
|
const result = await request<{ products: ShopifyProduct[] }>(fetchImpl, url, token);
|
|
164
253
|
if (!result.ok) return result;
|
|
165
254
|
const link = result.value.response.headers.get("link") ?? "";
|
|
166
255
|
const next = link.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;
|
|
167
256
|
return Ok({
|
|
168
|
-
items: result.value.data.products.map((product) =>
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
257
|
+
items: result.value.data.products.map((product) => {
|
|
258
|
+
const options = product.options?.map((option, index) => ({
|
|
259
|
+
name: option.name,
|
|
260
|
+
displayName: option.name,
|
|
261
|
+
...(option.position != null ? { sortOrder: option.position } : { sortOrder: index }),
|
|
262
|
+
values: (option.values ?? []).map((value, valueIndex) => ({ value, displayValue: value, sortOrder: valueIndex })),
|
|
263
|
+
}));
|
|
264
|
+
const variants = (product.variants ?? []).map((variant) => {
|
|
265
|
+
const selectors = [variant.option1, variant.option2, variant.option3];
|
|
266
|
+
const optionValues = Object.fromEntries((product.options ?? []).slice(0, 3).flatMap((option, index) => {
|
|
267
|
+
const value = selectors[index];
|
|
268
|
+
return value != null && value !== "" ? [[option.name, value] as const] : [];
|
|
269
|
+
}));
|
|
270
|
+
const prices = pricesForVariant(variant, currency);
|
|
271
|
+
return {
|
|
272
|
+
externalId: String(variant.id),
|
|
273
|
+
...(variant.sku ? { sku: variant.sku } : {}),
|
|
274
|
+
...(variant.barcode ? { barcode: variant.barcode } : {}),
|
|
275
|
+
...(Object.keys(optionValues).length > 0 ? { optionValues } : {}),
|
|
276
|
+
...(prices ? { prices } : {}),
|
|
277
|
+
};
|
|
278
|
+
});
|
|
279
|
+
const category = product.product_type ? slugify(product.product_type) : "";
|
|
280
|
+
const status = catalogStatus(product.status);
|
|
281
|
+
return {
|
|
282
|
+
externalId: String(product.id),
|
|
283
|
+
slug: product.handle ?? String(product.id),
|
|
284
|
+
title: product.title,
|
|
285
|
+
attributes: [{ locale: "en", title: product.title, ...(product.body_html != null ? { description: product.body_html } : {}) }],
|
|
286
|
+
variants,
|
|
287
|
+
...(product.images ? {
|
|
288
|
+
images: product.images.map((image, index) => ({
|
|
289
|
+
externalId: String(image.id),
|
|
290
|
+
url: image.src,
|
|
291
|
+
...(image.alt != null ? { alt: image.alt } : {}),
|
|
292
|
+
role: index === 0 ? "primary" as const : "gallery" as const,
|
|
293
|
+
...(image.position != null ? { sortOrder: image.position } : {}),
|
|
294
|
+
...(image.variant_ids != null ? { variantExternalIds: image.variant_ids.map(String) } : {}),
|
|
295
|
+
})),
|
|
296
|
+
} : {}),
|
|
297
|
+
...(options ? { options } : {}),
|
|
298
|
+
...(product.tags != null ? { tags: product.tags.split(",").map((tag) => tag.trim()).filter(Boolean) } : {}),
|
|
299
|
+
...(product.vendor ? { brand: product.vendor } : {}),
|
|
300
|
+
...(category ? { categories: [category] } : {}),
|
|
301
|
+
...(status ? { status } : {}),
|
|
302
|
+
};
|
|
303
|
+
}),
|
|
180
304
|
nextCursor: next,
|
|
181
305
|
});
|
|
182
306
|
},
|