@tallyui/connector-woocommerce 1.0.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.d.ts +58 -0
- package/dist/index.js +349 -0
- package/package.json +32 -0
- package/src/__tests__/product-traits.test.ts +191 -0
- package/src/index.ts +83 -0
- package/src/replication/products.test.ts +189 -0
- package/src/replication/products.ts +93 -0
- package/src/schemas/products.ts +150 -0
- package/src/sync/products.ts +70 -0
- package/src/traits/product.ts +48 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ProductTraits, CollectionSync, ReplicationAdapter, TallyConnector } from '@tallyui/core';
|
|
2
|
+
import { RxJsonSchema } from 'rxdb';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WooCommerce Product RxDB schema.
|
|
6
|
+
* Closely mirrors the WooCommerce REST API product shape.
|
|
7
|
+
* Ported from @wcpos/database.
|
|
8
|
+
*/
|
|
9
|
+
declare const wooProductSchema: RxJsonSchema<any>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* WooCommerce product trait implementations.
|
|
13
|
+
*
|
|
14
|
+
* Maps the WooCommerce REST API product shape to the universal
|
|
15
|
+
* Tally UI product interface.
|
|
16
|
+
*/
|
|
17
|
+
declare const wooProductTraits: ProductTraits;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* WooCommerce product sync implementation.
|
|
21
|
+
*
|
|
22
|
+
* Uses the WooCommerce REST API (or WCPOS API) to fetch products.
|
|
23
|
+
* For the MVP, this is a simple implementation that fetches all products.
|
|
24
|
+
* The full WCPOS sync engine has much more sophisticated diffing/queueing.
|
|
25
|
+
*/
|
|
26
|
+
declare const wooProductSync: CollectionSync;
|
|
27
|
+
|
|
28
|
+
type WooProductCheckpoint = {
|
|
29
|
+
id: string;
|
|
30
|
+
modified: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Replication adapter for WooCommerce products.
|
|
34
|
+
*
|
|
35
|
+
* Implements pull (cursor-based pagination by date_modified_gmt) and push
|
|
36
|
+
* (individual create/update via WC REST API v3). Designed for use with
|
|
37
|
+
* RxDB's replicateRxCollection.
|
|
38
|
+
*/
|
|
39
|
+
declare const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* WooCommerce connector for Tally UI.
|
|
43
|
+
*
|
|
44
|
+
* Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
|
|
45
|
+
* Products are stored in RxDB using a schema that mirrors the WC API shape.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { woocommerceConnector } from '@tallyui/connector-woocommerce';
|
|
49
|
+
* import { ConnectorProvider } from '@tallyui/core';
|
|
50
|
+
*
|
|
51
|
+
* <ConnectorProvider connector={woocommerceConnector}>
|
|
52
|
+
* <App />
|
|
53
|
+
* </ConnectorProvider>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
declare const woocommerceConnector: TallyConnector;
|
|
57
|
+
|
|
58
|
+
export { wooProductReplication, wooProductSchema, wooProductSync, wooProductTraits, woocommerceConnector };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// src/schemas/products.ts
|
|
2
|
+
var wooProductSchema = {
|
|
3
|
+
title: "WooCommerce Product",
|
|
4
|
+
version: 0,
|
|
5
|
+
type: "object",
|
|
6
|
+
primaryKey: "uuid",
|
|
7
|
+
properties: {
|
|
8
|
+
uuid: {
|
|
9
|
+
type: "string",
|
|
10
|
+
maxLength: 36
|
|
11
|
+
},
|
|
12
|
+
id: {
|
|
13
|
+
type: "integer",
|
|
14
|
+
multipleOf: 1,
|
|
15
|
+
minimum: 0,
|
|
16
|
+
maximum: 2147483647
|
|
17
|
+
},
|
|
18
|
+
name: {
|
|
19
|
+
type: "string"
|
|
20
|
+
},
|
|
21
|
+
slug: {
|
|
22
|
+
type: "string"
|
|
23
|
+
},
|
|
24
|
+
type: {
|
|
25
|
+
type: "string",
|
|
26
|
+
default: "simple",
|
|
27
|
+
enum: ["simple", "grouped", "external", "variable"]
|
|
28
|
+
},
|
|
29
|
+
status: {
|
|
30
|
+
type: "string",
|
|
31
|
+
default: "publish",
|
|
32
|
+
enum: ["draft", "pending", "private", "publish"]
|
|
33
|
+
},
|
|
34
|
+
sku: {
|
|
35
|
+
type: "string"
|
|
36
|
+
},
|
|
37
|
+
barcode: {
|
|
38
|
+
type: "string",
|
|
39
|
+
maxLength: 255
|
|
40
|
+
},
|
|
41
|
+
price: {
|
|
42
|
+
type: "string"
|
|
43
|
+
},
|
|
44
|
+
regular_price: {
|
|
45
|
+
type: "string"
|
|
46
|
+
},
|
|
47
|
+
sale_price: {
|
|
48
|
+
type: "string"
|
|
49
|
+
},
|
|
50
|
+
on_sale: {
|
|
51
|
+
type: "boolean"
|
|
52
|
+
},
|
|
53
|
+
description: {
|
|
54
|
+
type: "string"
|
|
55
|
+
},
|
|
56
|
+
short_description: {
|
|
57
|
+
type: "string"
|
|
58
|
+
},
|
|
59
|
+
stock_quantity: {
|
|
60
|
+
type: ["number", "null"]
|
|
61
|
+
},
|
|
62
|
+
stock_status: {
|
|
63
|
+
type: "string",
|
|
64
|
+
default: "instock",
|
|
65
|
+
enum: ["instock", "outofstock", "onbackorder", "lowstock"],
|
|
66
|
+
maxLength: 255
|
|
67
|
+
},
|
|
68
|
+
manage_stock: {
|
|
69
|
+
type: "boolean",
|
|
70
|
+
default: false
|
|
71
|
+
},
|
|
72
|
+
categories: {
|
|
73
|
+
type: "array",
|
|
74
|
+
items: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: {
|
|
77
|
+
id: { type: "integer" },
|
|
78
|
+
name: { type: "string" },
|
|
79
|
+
slug: { type: "string" }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
tags: {
|
|
84
|
+
type: "array",
|
|
85
|
+
items: {
|
|
86
|
+
type: "object",
|
|
87
|
+
properties: {
|
|
88
|
+
id: { type: "integer" },
|
|
89
|
+
name: { type: "string" },
|
|
90
|
+
slug: { type: "string" }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
images: {
|
|
95
|
+
type: "array",
|
|
96
|
+
items: {
|
|
97
|
+
type: "object",
|
|
98
|
+
properties: {
|
|
99
|
+
id: { type: "integer" },
|
|
100
|
+
src: { type: "string" },
|
|
101
|
+
name: { type: "string" },
|
|
102
|
+
alt: { type: "string" }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
attributes: {
|
|
107
|
+
type: "array",
|
|
108
|
+
items: {
|
|
109
|
+
type: "object",
|
|
110
|
+
properties: {
|
|
111
|
+
id: { type: "integer" },
|
|
112
|
+
name: { type: "string" },
|
|
113
|
+
position: { type: "integer" },
|
|
114
|
+
visible: { type: "boolean" },
|
|
115
|
+
variation: { type: "boolean" },
|
|
116
|
+
options: {
|
|
117
|
+
type: "array",
|
|
118
|
+
items: { type: "string" }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
variations: {
|
|
124
|
+
type: "array",
|
|
125
|
+
items: { type: "integer" }
|
|
126
|
+
},
|
|
127
|
+
meta_data: {
|
|
128
|
+
type: "array",
|
|
129
|
+
items: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
id: { type: "integer" },
|
|
133
|
+
key: { type: "string" },
|
|
134
|
+
value: { type: "string" }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
date_modified_gmt: {
|
|
139
|
+
type: "string"
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
indexes: ["id", "barcode", "stock_status"],
|
|
143
|
+
required: ["uuid"]
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// src/traits/product.ts
|
|
147
|
+
var wooProductTraits = {
|
|
148
|
+
getId: (doc) => String(doc.id),
|
|
149
|
+
getName: (doc) => doc.name ?? "",
|
|
150
|
+
getSku: (doc) => doc.sku || void 0,
|
|
151
|
+
getPrice: (doc) => doc.price || void 0,
|
|
152
|
+
getRegularPrice: (doc) => doc.regular_price || void 0,
|
|
153
|
+
getSalePrice: (doc) => doc.sale_price || void 0,
|
|
154
|
+
isOnSale: (doc) => doc.on_sale === true,
|
|
155
|
+
getImageUrl: (doc) => doc.images?.[0]?.src || void 0,
|
|
156
|
+
getImageUrls: (doc) => (doc.images ?? []).map((img) => img.src).filter(Boolean),
|
|
157
|
+
getDescription: (doc) => doc.short_description || doc.description || void 0,
|
|
158
|
+
getStockStatus: (doc) => {
|
|
159
|
+
const status = doc.stock_status;
|
|
160
|
+
if (status === "instock" || status === "outofstock" || status === "onbackorder") {
|
|
161
|
+
return status;
|
|
162
|
+
}
|
|
163
|
+
return "unknown";
|
|
164
|
+
},
|
|
165
|
+
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
166
|
+
hasVariants: (doc) => doc.type === "variable",
|
|
167
|
+
getType: (doc) => doc.type ?? "simple",
|
|
168
|
+
getBarcode: (doc) => doc.barcode || void 0,
|
|
169
|
+
getCategoryNames: (doc) => (doc.categories ?? []).map((cat) => cat.name).filter(Boolean)
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// src/sync/products.ts
|
|
173
|
+
var wooProductSync = {
|
|
174
|
+
async fetchAllIds(context) {
|
|
175
|
+
const response = await fetch(`${context.baseUrl}/products`, {
|
|
176
|
+
headers: {
|
|
177
|
+
...context.headers,
|
|
178
|
+
"Content-Type": "application/json"
|
|
179
|
+
},
|
|
180
|
+
signal: context.signal
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
184
|
+
}
|
|
185
|
+
const products = await response.json();
|
|
186
|
+
return products.map((p) => ({
|
|
187
|
+
id: String(p.id),
|
|
188
|
+
dateModified: p.date_modified_gmt
|
|
189
|
+
}));
|
|
190
|
+
},
|
|
191
|
+
async fetchByIds(ids, context) {
|
|
192
|
+
const include = ids.join(",");
|
|
193
|
+
const response = await fetch(
|
|
194
|
+
`${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
|
|
195
|
+
{
|
|
196
|
+
headers: {
|
|
197
|
+
...context.headers,
|
|
198
|
+
"Content-Type": "application/json"
|
|
199
|
+
},
|
|
200
|
+
signal: context.signal
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
205
|
+
}
|
|
206
|
+
return response.json();
|
|
207
|
+
},
|
|
208
|
+
async fetchModifiedAfter(date, context) {
|
|
209
|
+
const response = await fetch(
|
|
210
|
+
`${context.baseUrl}/products?modified_after=${date}&per_page=100`,
|
|
211
|
+
{
|
|
212
|
+
headers: {
|
|
213
|
+
...context.headers,
|
|
214
|
+
"Content-Type": "application/json"
|
|
215
|
+
},
|
|
216
|
+
signal: context.signal
|
|
217
|
+
}
|
|
218
|
+
);
|
|
219
|
+
if (!response.ok) {
|
|
220
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
221
|
+
}
|
|
222
|
+
return response.json();
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/replication/products.ts
|
|
227
|
+
var wooProductReplication = {
|
|
228
|
+
pull: {
|
|
229
|
+
async handler(lastCheckpoint, batchSize, context) {
|
|
230
|
+
const params = new URLSearchParams({
|
|
231
|
+
per_page: String(batchSize),
|
|
232
|
+
orderby: "modified",
|
|
233
|
+
order: "asc"
|
|
234
|
+
});
|
|
235
|
+
if (lastCheckpoint?.modified) {
|
|
236
|
+
params.set("modified_after", lastCheckpoint.modified);
|
|
237
|
+
}
|
|
238
|
+
const response = await fetch(`${context.baseUrl}/products?${params}`, {
|
|
239
|
+
headers: {
|
|
240
|
+
...context.headers,
|
|
241
|
+
"Content-Type": "application/json"
|
|
242
|
+
},
|
|
243
|
+
signal: context.signal
|
|
244
|
+
});
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
247
|
+
}
|
|
248
|
+
const products = await response.json();
|
|
249
|
+
const documents = products.map((p) => ({ ...p, _deleted: false }));
|
|
250
|
+
const checkpoint = products.length > 0 ? {
|
|
251
|
+
id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
|
|
252
|
+
modified: products[products.length - 1].date_modified_gmt
|
|
253
|
+
} : lastCheckpoint ?? { id: "", modified: "" };
|
|
254
|
+
return { documents, checkpoint };
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
push: {
|
|
258
|
+
async handler(changeRows, context) {
|
|
259
|
+
const conflicts = [];
|
|
260
|
+
for (const row of changeRows) {
|
|
261
|
+
const doc = row.newDocumentState;
|
|
262
|
+
try {
|
|
263
|
+
const isCreate = !row.assumedMasterState;
|
|
264
|
+
const method = isCreate ? "POST" : "PUT";
|
|
265
|
+
const url = isCreate ? `${context.baseUrl}/products` : `${context.baseUrl}/products/${doc.id}`;
|
|
266
|
+
const response = await fetch(url, {
|
|
267
|
+
method,
|
|
268
|
+
headers: {
|
|
269
|
+
...context.headers,
|
|
270
|
+
"Content-Type": "application/json"
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify(doc),
|
|
273
|
+
signal: context.signal
|
|
274
|
+
});
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
if (row.assumedMasterState) {
|
|
277
|
+
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
if (row.assumedMasterState) {
|
|
282
|
+
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return conflicts;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/index.ts
|
|
292
|
+
var woocommerceConnector = {
|
|
293
|
+
id: "woocommerce",
|
|
294
|
+
name: "WooCommerce",
|
|
295
|
+
description: "Connect to WooCommerce stores via the REST API",
|
|
296
|
+
icon: void 0,
|
|
297
|
+
// TODO: WooCommerce logo
|
|
298
|
+
auth: {
|
|
299
|
+
type: "WooCommerce REST API",
|
|
300
|
+
fields: [
|
|
301
|
+
{
|
|
302
|
+
key: "url",
|
|
303
|
+
label: "Store URL",
|
|
304
|
+
type: "url",
|
|
305
|
+
placeholder: "https://mystore.com",
|
|
306
|
+
required: true
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
key: "consumer_key",
|
|
310
|
+
label: "Consumer Key",
|
|
311
|
+
type: "text",
|
|
312
|
+
placeholder: "ck_...",
|
|
313
|
+
required: true
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: "consumer_secret",
|
|
317
|
+
label: "Consumer Secret",
|
|
318
|
+
type: "password",
|
|
319
|
+
placeholder: "cs_...",
|
|
320
|
+
required: true
|
|
321
|
+
}
|
|
322
|
+
],
|
|
323
|
+
getHeaders: (credentials) => {
|
|
324
|
+
const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
|
|
325
|
+
return {
|
|
326
|
+
Authorization: `Basic ${encoded}`
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
schemas: {
|
|
331
|
+
products: wooProductSchema
|
|
332
|
+
},
|
|
333
|
+
traits: {
|
|
334
|
+
product: wooProductTraits
|
|
335
|
+
},
|
|
336
|
+
sync: {
|
|
337
|
+
products: wooProductSync
|
|
338
|
+
},
|
|
339
|
+
replication: {
|
|
340
|
+
products: wooProductReplication
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
export {
|
|
344
|
+
wooProductReplication,
|
|
345
|
+
wooProductSchema,
|
|
346
|
+
wooProductSync,
|
|
347
|
+
wooProductTraits,
|
|
348
|
+
woocommerceConnector
|
|
349
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tallyui/connector-woocommerce",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "WooCommerce connector for Tally UI",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"source": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"source": "./src/index.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@tallyui/core": "0.2.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"rxdb": "16.21.1"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsup",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"test": "echo \"no tests yet\""
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { wooProductTraits } from '../traits/product';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Realistic WooCommerce product document, shaped like the REST API v3 response.
|
|
6
|
+
*/
|
|
7
|
+
const fullProduct = {
|
|
8
|
+
id: 42,
|
|
9
|
+
name: 'Espresso Machine Pro',
|
|
10
|
+
slug: 'espresso-machine-pro',
|
|
11
|
+
type: 'simple',
|
|
12
|
+
status: 'publish',
|
|
13
|
+
sku: 'ESP-001',
|
|
14
|
+
price: '599.99',
|
|
15
|
+
regular_price: '599.99',
|
|
16
|
+
sale_price: '',
|
|
17
|
+
on_sale: false,
|
|
18
|
+
stock_status: 'instock',
|
|
19
|
+
stock_quantity: 15,
|
|
20
|
+
barcode: '1234567890123',
|
|
21
|
+
short_description: 'High-end espresso machine.',
|
|
22
|
+
description: 'Full description here.',
|
|
23
|
+
images: [
|
|
24
|
+
{ id: 1, src: 'https://store.example/espresso.jpg', alt: 'Espresso Machine' },
|
|
25
|
+
{ id: 2, src: 'https://store.example/espresso-side.jpg', alt: '' },
|
|
26
|
+
],
|
|
27
|
+
categories: [
|
|
28
|
+
{ id: 1, name: 'Equipment', slug: 'equipment' },
|
|
29
|
+
{ id: 2, name: 'Coffee', slug: 'coffee' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const saleProduct = {
|
|
34
|
+
...fullProduct,
|
|
35
|
+
id: 43,
|
|
36
|
+
price: '499.99',
|
|
37
|
+
sale_price: '499.99',
|
|
38
|
+
on_sale: true,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const variableProduct = {
|
|
42
|
+
...fullProduct,
|
|
43
|
+
id: 44,
|
|
44
|
+
type: 'variable',
|
|
45
|
+
variations: [101, 102],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const minimalProduct = {
|
|
49
|
+
id: 99,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe('WooCommerce product traits', () => {
|
|
53
|
+
describe('getId', () => {
|
|
54
|
+
it('returns the product id as a string', () => {
|
|
55
|
+
expect(wooProductTraits.getId(fullProduct)).toBe('42');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('getName', () => {
|
|
60
|
+
it('returns the product name', () => {
|
|
61
|
+
expect(wooProductTraits.getName(fullProduct)).toBe('Espresso Machine Pro');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns empty string for missing name', () => {
|
|
65
|
+
expect(wooProductTraits.getName(minimalProduct)).toBe('');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('getSku', () => {
|
|
70
|
+
it('returns the SKU', () => {
|
|
71
|
+
expect(wooProductTraits.getSku(fullProduct)).toBe('ESP-001');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns undefined when no SKU', () => {
|
|
75
|
+
expect(wooProductTraits.getSku(minimalProduct)).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('getPrice', () => {
|
|
80
|
+
it('returns the price string', () => {
|
|
81
|
+
expect(wooProductTraits.getPrice(fullProduct)).toBe('599.99');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('returns undefined for missing price', () => {
|
|
85
|
+
expect(wooProductTraits.getPrice(minimalProduct)).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('getRegularPrice / getSalePrice / isOnSale', () => {
|
|
90
|
+
it('returns regular price', () => {
|
|
91
|
+
expect(wooProductTraits.getRegularPrice(fullProduct)).toBe('599.99');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('detects non-sale product', () => {
|
|
95
|
+
expect(wooProductTraits.isOnSale(fullProduct)).toBe(false);
|
|
96
|
+
expect(wooProductTraits.getSalePrice(fullProduct)).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('detects sale product', () => {
|
|
100
|
+
expect(wooProductTraits.isOnSale(saleProduct)).toBe(true);
|
|
101
|
+
expect(wooProductTraits.getSalePrice(saleProduct)).toBe('499.99');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getImageUrl / getImageUrls', () => {
|
|
106
|
+
it('returns the first image src', () => {
|
|
107
|
+
expect(wooProductTraits.getImageUrl(fullProduct)).toBe('https://store.example/espresso.jpg');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('returns all image URLs', () => {
|
|
111
|
+
expect(wooProductTraits.getImageUrls(fullProduct)).toEqual([
|
|
112
|
+
'https://store.example/espresso.jpg',
|
|
113
|
+
'https://store.example/espresso-side.jpg',
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('returns undefined / empty for missing images', () => {
|
|
118
|
+
expect(wooProductTraits.getImageUrl(minimalProduct)).toBeUndefined();
|
|
119
|
+
expect(wooProductTraits.getImageUrls(minimalProduct)).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('getDescription', () => {
|
|
124
|
+
it('prefers short_description', () => {
|
|
125
|
+
expect(wooProductTraits.getDescription(fullProduct)).toBe('High-end espresso machine.');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('falls back to description', () => {
|
|
129
|
+
const doc = { ...fullProduct, short_description: '' };
|
|
130
|
+
expect(wooProductTraits.getDescription(doc)).toBe('Full description here.');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('returns undefined when neither exists', () => {
|
|
134
|
+
expect(wooProductTraits.getDescription(minimalProduct)).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('getStockStatus / getStockQuantity', () => {
|
|
139
|
+
it('returns instock status', () => {
|
|
140
|
+
expect(wooProductTraits.getStockStatus(fullProduct)).toBe('instock');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns stock quantity', () => {
|
|
144
|
+
expect(wooProductTraits.getStockQuantity(fullProduct)).toBe(15);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('returns unknown for unrecognised status', () => {
|
|
148
|
+
expect(wooProductTraits.getStockStatus({ ...fullProduct, stock_status: 'weird' })).toBe('unknown');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null quantity when missing', () => {
|
|
152
|
+
expect(wooProductTraits.getStockQuantity(minimalProduct)).toBeNull();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('hasVariants / getType', () => {
|
|
157
|
+
it('simple product has no variants', () => {
|
|
158
|
+
expect(wooProductTraits.hasVariants(fullProduct)).toBe(false);
|
|
159
|
+
expect(wooProductTraits.getType(fullProduct)).toBe('simple');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('variable product has variants', () => {
|
|
163
|
+
expect(wooProductTraits.hasVariants(variableProduct)).toBe(true);
|
|
164
|
+
expect(wooProductTraits.getType(variableProduct)).toBe('variable');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('defaults to simple for missing type', () => {
|
|
168
|
+
expect(wooProductTraits.getType(minimalProduct)).toBe('simple');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('getBarcode', () => {
|
|
173
|
+
it('returns barcode', () => {
|
|
174
|
+
expect(wooProductTraits.getBarcode(fullProduct)).toBe('1234567890123');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('returns undefined when missing', () => {
|
|
178
|
+
expect(wooProductTraits.getBarcode(minimalProduct)).toBeUndefined();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('getCategoryNames', () => {
|
|
183
|
+
it('returns category name strings', () => {
|
|
184
|
+
expect(wooProductTraits.getCategoryNames(fullProduct)).toEqual(['Equipment', 'Coffee']);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns empty array when no categories', () => {
|
|
188
|
+
expect(wooProductTraits.getCategoryNames(minimalProduct)).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { TallyConnector } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
import { wooProductSchema } from './schemas/products';
|
|
4
|
+
import { wooProductTraits } from './traits/product';
|
|
5
|
+
import { wooProductSync } from './sync/products';
|
|
6
|
+
import { wooProductReplication } from './replication/products';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WooCommerce connector for Tally UI.
|
|
10
|
+
*
|
|
11
|
+
* Connects to WooCommerce sites via the REST API (v3) or WCPOS API.
|
|
12
|
+
* Products are stored in RxDB using a schema that mirrors the WC API shape.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { woocommerceConnector } from '@tallyui/connector-woocommerce';
|
|
16
|
+
* import { ConnectorProvider } from '@tallyui/core';
|
|
17
|
+
*
|
|
18
|
+
* <ConnectorProvider connector={woocommerceConnector}>
|
|
19
|
+
* <App />
|
|
20
|
+
* </ConnectorProvider>
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const woocommerceConnector: TallyConnector = {
|
|
24
|
+
id: 'woocommerce',
|
|
25
|
+
name: 'WooCommerce',
|
|
26
|
+
description: 'Connect to WooCommerce stores via the REST API',
|
|
27
|
+
icon: undefined, // TODO: WooCommerce logo
|
|
28
|
+
|
|
29
|
+
auth: {
|
|
30
|
+
type: 'WooCommerce REST API',
|
|
31
|
+
fields: [
|
|
32
|
+
{
|
|
33
|
+
key: 'url',
|
|
34
|
+
label: 'Store URL',
|
|
35
|
+
type: 'url',
|
|
36
|
+
placeholder: 'https://mystore.com',
|
|
37
|
+
required: true,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
key: 'consumer_key',
|
|
41
|
+
label: 'Consumer Key',
|
|
42
|
+
type: 'text',
|
|
43
|
+
placeholder: 'ck_...',
|
|
44
|
+
required: true,
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'consumer_secret',
|
|
48
|
+
label: 'Consumer Secret',
|
|
49
|
+
type: 'password',
|
|
50
|
+
placeholder: 'cs_...',
|
|
51
|
+
required: true,
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
getHeaders: (credentials) => {
|
|
55
|
+
const encoded = btoa(`${credentials.consumer_key}:${credentials.consumer_secret}`);
|
|
56
|
+
return {
|
|
57
|
+
Authorization: `Basic ${encoded}`,
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
schemas: {
|
|
63
|
+
products: wooProductSchema,
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
traits: {
|
|
67
|
+
product: wooProductTraits,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
sync: {
|
|
71
|
+
products: wooProductSync,
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
replication: {
|
|
75
|
+
products: wooProductReplication,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Re-export pieces for advanced usage
|
|
80
|
+
export { wooProductSchema } from './schemas/products';
|
|
81
|
+
export { wooProductTraits } from './traits/product';
|
|
82
|
+
export { wooProductSync } from './sync/products';
|
|
83
|
+
export { wooProductReplication } from './replication/products';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type { SyncContext } from '@tallyui/core';
|
|
3
|
+
|
|
4
|
+
import { wooProductReplication } from './products';
|
|
5
|
+
|
|
6
|
+
const context: SyncContext = {
|
|
7
|
+
connectorId: 'woocommerce',
|
|
8
|
+
baseUrl: 'https://example.com/wp-json/wc/v3',
|
|
9
|
+
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe('wooProductReplication.pull.handler', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.restoreAllMocks();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('fetches products from initial checkpoint (undefined)', async () => {
|
|
18
|
+
const mockProducts = [
|
|
19
|
+
{ id: 1, uuid: 'abc', name: 'Widget', date_modified_gmt: '2026-01-01T00:00:00' },
|
|
20
|
+
{ id: 2, uuid: 'def', name: 'Gadget', date_modified_gmt: '2026-01-02T00:00:00' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
24
|
+
new Response(JSON.stringify(mockProducts), { status: 200 }),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const result = await wooProductReplication.pull.handler(undefined, 100, context);
|
|
28
|
+
|
|
29
|
+
expect(result.documents).toHaveLength(2);
|
|
30
|
+
expect(result.documents[0]._deleted).toBe(false);
|
|
31
|
+
expect(result.checkpoint).toEqual({
|
|
32
|
+
id: 'def',
|
|
33
|
+
modified: '2026-01-02T00:00:00',
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('fetches products after a checkpoint', async () => {
|
|
38
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
39
|
+
new Response(JSON.stringify([]), { status: 200 }),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
|
|
43
|
+
const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
|
|
44
|
+
|
|
45
|
+
expect(result.documents).toHaveLength(0);
|
|
46
|
+
|
|
47
|
+
const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
|
|
48
|
+
expect(calledUrl).toContain('modified_after=2026-01-01T00%3A00%3A00');
|
|
49
|
+
expect(calledUrl).toContain('orderby=modified');
|
|
50
|
+
expect(calledUrl).toContain('order=asc');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('uses batchSize as per_page', async () => {
|
|
54
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
55
|
+
new Response(JSON.stringify([]), { status: 200 }),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
await wooProductReplication.pull.handler(undefined, 25, context);
|
|
59
|
+
|
|
60
|
+
const calledUrl = (globalThis.fetch as any).mock.calls[0][0];
|
|
61
|
+
expect(calledUrl).toContain('per_page=25');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws on non-OK response', async () => {
|
|
65
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
66
|
+
new Response('Internal Server Error', { status: 500 }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await expect(
|
|
70
|
+
wooProductReplication.pull.handler(undefined, 100, context),
|
|
71
|
+
).rejects.toThrow('WooCommerce API error: 500');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns lastCheckpoint when no products are returned', async () => {
|
|
75
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
76
|
+
new Response(JSON.stringify([]), { status: 200 }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const checkpoint = { id: 'abc', modified: '2026-01-01T00:00:00' };
|
|
80
|
+
const result = await wooProductReplication.pull.handler(checkpoint, 100, context);
|
|
81
|
+
|
|
82
|
+
expect(result.checkpoint).toEqual(checkpoint);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('falls back to id when uuid is missing', async () => {
|
|
86
|
+
const mockProducts = [
|
|
87
|
+
{ id: 42, name: 'No UUID', date_modified_gmt: '2026-03-01T00:00:00' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
91
|
+
new Response(JSON.stringify(mockProducts), { status: 200 }),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const result = await wooProductReplication.pull.handler(undefined, 100, context);
|
|
95
|
+
|
|
96
|
+
expect(result.checkpoint.id).toBe('42');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('wooProductReplication.push.handler', () => {
|
|
101
|
+
beforeEach(() => {
|
|
102
|
+
vi.restoreAllMocks();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('sends PUT for updates', async () => {
|
|
106
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
107
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const conflicts = await wooProductReplication.push!.handler(
|
|
111
|
+
[{
|
|
112
|
+
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
113
|
+
assumedMasterState: { id: '1', name: 'Old', _deleted: false } as any,
|
|
114
|
+
}],
|
|
115
|
+
context,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(conflicts).toHaveLength(0);
|
|
119
|
+
const [url, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
120
|
+
expect(url).toContain('/products/1');
|
|
121
|
+
expect(opts.method).toBe('PUT');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('sends POST for creates', async () => {
|
|
125
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
126
|
+
new Response(JSON.stringify({}), { status: 200 }),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const conflicts = await wooProductReplication.push!.handler(
|
|
130
|
+
[{
|
|
131
|
+
newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
|
|
132
|
+
// No assumedMasterState = create
|
|
133
|
+
}],
|
|
134
|
+
context,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(conflicts).toHaveLength(0);
|
|
138
|
+
const [, opts] = (globalThis.fetch as any).mock.calls[0];
|
|
139
|
+
expect(opts.method).toBe('POST');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('returns conflicts on server error', async () => {
|
|
143
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
144
|
+
new Response('conflict', { status: 409 }),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const conflicts = await wooProductReplication.push!.handler(
|
|
148
|
+
[{
|
|
149
|
+
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
150
|
+
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
151
|
+
}],
|
|
152
|
+
context,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(conflicts).toHaveLength(1);
|
|
156
|
+
expect(conflicts[0].name).toBe('Server Version');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('returns conflicts on network failure', async () => {
|
|
160
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error'));
|
|
161
|
+
|
|
162
|
+
const conflicts = await wooProductReplication.push!.handler(
|
|
163
|
+
[{
|
|
164
|
+
newDocumentState: { id: '1', name: 'Updated', _deleted: false } as any,
|
|
165
|
+
assumedMasterState: { id: '1', name: 'Server Version', _deleted: false } as any,
|
|
166
|
+
}],
|
|
167
|
+
context,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(conflicts).toHaveLength(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('does not return conflict for creates that fail', async () => {
|
|
174
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
|
175
|
+
new Response('error', { status: 500 }),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const conflicts = await wooProductReplication.push!.handler(
|
|
179
|
+
[{
|
|
180
|
+
newDocumentState: { id: '1', name: 'New Product', _deleted: false } as any,
|
|
181
|
+
// No assumedMasterState = create, so no conflict to report
|
|
182
|
+
}],
|
|
183
|
+
context,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Creates without assumedMasterState don't produce conflicts
|
|
187
|
+
expect(conflicts).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ReplicationAdapter, SyncContext } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
export type WooProductCheckpoint = {
|
|
4
|
+
id: string;
|
|
5
|
+
modified: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Replication adapter for WooCommerce products.
|
|
10
|
+
*
|
|
11
|
+
* Implements pull (cursor-based pagination by date_modified_gmt) and push
|
|
12
|
+
* (individual create/update via WC REST API v3). Designed for use with
|
|
13
|
+
* RxDB's replicateRxCollection.
|
|
14
|
+
*/
|
|
15
|
+
export const wooProductReplication: ReplicationAdapter<any, WooProductCheckpoint> = {
|
|
16
|
+
pull: {
|
|
17
|
+
async handler(lastCheckpoint, batchSize, context) {
|
|
18
|
+
const params = new URLSearchParams({
|
|
19
|
+
per_page: String(batchSize),
|
|
20
|
+
orderby: 'modified',
|
|
21
|
+
order: 'asc',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (lastCheckpoint?.modified) {
|
|
25
|
+
params.set('modified_after', lastCheckpoint.modified);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const response = await fetch(`${context.baseUrl}/products?${params}`, {
|
|
29
|
+
headers: {
|
|
30
|
+
...context.headers,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
},
|
|
33
|
+
signal: context.signal,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const products: any[] = await response.json();
|
|
41
|
+
const documents = products.map((p) => ({ ...p, _deleted: false }));
|
|
42
|
+
|
|
43
|
+
const checkpoint: WooProductCheckpoint = products.length > 0
|
|
44
|
+
? {
|
|
45
|
+
id: String(products[products.length - 1].uuid ?? products[products.length - 1].id),
|
|
46
|
+
modified: products[products.length - 1].date_modified_gmt,
|
|
47
|
+
}
|
|
48
|
+
: lastCheckpoint ?? { id: '', modified: '' };
|
|
49
|
+
|
|
50
|
+
return { documents, checkpoint };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
push: {
|
|
55
|
+
async handler(changeRows, context) {
|
|
56
|
+
const conflicts: any[] = [];
|
|
57
|
+
|
|
58
|
+
for (const row of changeRows) {
|
|
59
|
+
const doc = row.newDocumentState as any;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const isCreate = !row.assumedMasterState;
|
|
63
|
+
const method = isCreate ? 'POST' : 'PUT';
|
|
64
|
+
const url = isCreate
|
|
65
|
+
? `${context.baseUrl}/products`
|
|
66
|
+
: `${context.baseUrl}/products/${doc.id}`;
|
|
67
|
+
|
|
68
|
+
const response = await fetch(url, {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
...context.headers,
|
|
72
|
+
'Content-Type': 'application/json',
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(doc),
|
|
75
|
+
signal: context.signal,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
if (row.assumedMasterState) {
|
|
80
|
+
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
if (row.assumedMasterState) {
|
|
85
|
+
conflicts.push({ ...row.assumedMasterState, _deleted: false });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return conflicts;
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { RxJsonSchema } from 'rxdb';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WooCommerce Product RxDB schema.
|
|
5
|
+
* Closely mirrors the WooCommerce REST API product shape.
|
|
6
|
+
* Ported from @wcpos/database.
|
|
7
|
+
*/
|
|
8
|
+
export const wooProductSchema: RxJsonSchema<any> = {
|
|
9
|
+
title: 'WooCommerce Product',
|
|
10
|
+
version: 0,
|
|
11
|
+
type: 'object',
|
|
12
|
+
primaryKey: 'uuid',
|
|
13
|
+
properties: {
|
|
14
|
+
uuid: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
maxLength: 36,
|
|
17
|
+
},
|
|
18
|
+
id: {
|
|
19
|
+
type: 'integer',
|
|
20
|
+
multipleOf: 1,
|
|
21
|
+
minimum: 0,
|
|
22
|
+
maximum: 2147483647,
|
|
23
|
+
},
|
|
24
|
+
name: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
},
|
|
27
|
+
slug: {
|
|
28
|
+
type: 'string',
|
|
29
|
+
},
|
|
30
|
+
type: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
default: 'simple',
|
|
33
|
+
enum: ['simple', 'grouped', 'external', 'variable'],
|
|
34
|
+
},
|
|
35
|
+
status: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
default: 'publish',
|
|
38
|
+
enum: ['draft', 'pending', 'private', 'publish'],
|
|
39
|
+
},
|
|
40
|
+
sku: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
},
|
|
43
|
+
barcode: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
maxLength: 255,
|
|
46
|
+
},
|
|
47
|
+
price: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
},
|
|
50
|
+
regular_price: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
},
|
|
53
|
+
sale_price: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
},
|
|
56
|
+
on_sale: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
},
|
|
59
|
+
description: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
},
|
|
62
|
+
short_description: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
},
|
|
65
|
+
stock_quantity: {
|
|
66
|
+
type: ['number', 'null'],
|
|
67
|
+
},
|
|
68
|
+
stock_status: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
default: 'instock',
|
|
71
|
+
enum: ['instock', 'outofstock', 'onbackorder', 'lowstock'],
|
|
72
|
+
maxLength: 255,
|
|
73
|
+
},
|
|
74
|
+
manage_stock: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
default: false,
|
|
77
|
+
},
|
|
78
|
+
categories: {
|
|
79
|
+
type: 'array',
|
|
80
|
+
items: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
id: { type: 'integer' },
|
|
84
|
+
name: { type: 'string' },
|
|
85
|
+
slug: { type: 'string' },
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
tags: {
|
|
90
|
+
type: 'array',
|
|
91
|
+
items: {
|
|
92
|
+
type: 'object',
|
|
93
|
+
properties: {
|
|
94
|
+
id: { type: 'integer' },
|
|
95
|
+
name: { type: 'string' },
|
|
96
|
+
slug: { type: 'string' },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
images: {
|
|
101
|
+
type: 'array',
|
|
102
|
+
items: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
properties: {
|
|
105
|
+
id: { type: 'integer' },
|
|
106
|
+
src: { type: 'string' },
|
|
107
|
+
name: { type: 'string' },
|
|
108
|
+
alt: { type: 'string' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
attributes: {
|
|
113
|
+
type: 'array',
|
|
114
|
+
items: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
id: { type: 'integer' },
|
|
118
|
+
name: { type: 'string' },
|
|
119
|
+
position: { type: 'integer' },
|
|
120
|
+
visible: { type: 'boolean' },
|
|
121
|
+
variation: { type: 'boolean' },
|
|
122
|
+
options: {
|
|
123
|
+
type: 'array',
|
|
124
|
+
items: { type: 'string' },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
variations: {
|
|
130
|
+
type: 'array',
|
|
131
|
+
items: { type: 'integer' },
|
|
132
|
+
},
|
|
133
|
+
meta_data: {
|
|
134
|
+
type: 'array',
|
|
135
|
+
items: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: {
|
|
138
|
+
id: { type: 'integer' },
|
|
139
|
+
key: { type: 'string' },
|
|
140
|
+
value: { type: 'string' },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
date_modified_gmt: {
|
|
145
|
+
type: 'string',
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
indexes: ['id', 'barcode', 'stock_status'],
|
|
149
|
+
required: ['uuid'],
|
|
150
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CollectionSync, SyncContext, RemoteIdEntry } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WooCommerce product sync implementation.
|
|
5
|
+
*
|
|
6
|
+
* Uses the WooCommerce REST API (or WCPOS API) to fetch products.
|
|
7
|
+
* For the MVP, this is a simple implementation that fetches all products.
|
|
8
|
+
* The full WCPOS sync engine has much more sophisticated diffing/queueing.
|
|
9
|
+
*/
|
|
10
|
+
export const wooProductSync: CollectionSync = {
|
|
11
|
+
async fetchAllIds(context: SyncContext): Promise<RemoteIdEntry[]> {
|
|
12
|
+
const response = await fetch(`${context.baseUrl}/products`, {
|
|
13
|
+
headers: {
|
|
14
|
+
...context.headers,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
signal: context.signal,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const products = await response.json();
|
|
25
|
+
return products.map((p: any) => ({
|
|
26
|
+
id: String(p.id),
|
|
27
|
+
dateModified: p.date_modified_gmt,
|
|
28
|
+
}));
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
async fetchByIds(ids: string[], context: SyncContext): Promise<any[]> {
|
|
32
|
+
// WooCommerce supports ?include=1,2,3 for bulk fetching
|
|
33
|
+
const include = ids.join(',');
|
|
34
|
+
const response = await fetch(
|
|
35
|
+
`${context.baseUrl}/products?include=${include}&per_page=${ids.length}`,
|
|
36
|
+
{
|
|
37
|
+
headers: {
|
|
38
|
+
...context.headers,
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
},
|
|
41
|
+
signal: context.signal,
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return response.json();
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
async fetchModifiedAfter(date: string, context: SyncContext): Promise<any[]> {
|
|
53
|
+
const response = await fetch(
|
|
54
|
+
`${context.baseUrl}/products?modified_after=${date}&per_page=100`,
|
|
55
|
+
{
|
|
56
|
+
headers: {
|
|
57
|
+
...context.headers,
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
},
|
|
60
|
+
signal: context.signal,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`WooCommerce API error: ${response.status}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return response.json();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ProductTraits } from '@tallyui/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WooCommerce product trait implementations.
|
|
5
|
+
*
|
|
6
|
+
* Maps the WooCommerce REST API product shape to the universal
|
|
7
|
+
* Tally UI product interface.
|
|
8
|
+
*/
|
|
9
|
+
export const wooProductTraits: ProductTraits = {
|
|
10
|
+
getId: (doc) => String(doc.id),
|
|
11
|
+
|
|
12
|
+
getName: (doc) => doc.name ?? '',
|
|
13
|
+
|
|
14
|
+
getSku: (doc) => doc.sku || undefined,
|
|
15
|
+
|
|
16
|
+
getPrice: (doc) => doc.price || undefined,
|
|
17
|
+
|
|
18
|
+
getRegularPrice: (doc) => doc.regular_price || undefined,
|
|
19
|
+
|
|
20
|
+
getSalePrice: (doc) => doc.sale_price || undefined,
|
|
21
|
+
|
|
22
|
+
isOnSale: (doc) => doc.on_sale === true,
|
|
23
|
+
|
|
24
|
+
getImageUrl: (doc) => doc.images?.[0]?.src || undefined,
|
|
25
|
+
|
|
26
|
+
getImageUrls: (doc) => (doc.images ?? []).map((img: any) => img.src).filter(Boolean),
|
|
27
|
+
|
|
28
|
+
getDescription: (doc) => doc.short_description || doc.description || undefined,
|
|
29
|
+
|
|
30
|
+
getStockStatus: (doc) => {
|
|
31
|
+
const status = doc.stock_status;
|
|
32
|
+
if (status === 'instock' || status === 'outofstock' || status === 'onbackorder') {
|
|
33
|
+
return status;
|
|
34
|
+
}
|
|
35
|
+
return 'unknown';
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
getStockQuantity: (doc) => doc.stock_quantity ?? null,
|
|
39
|
+
|
|
40
|
+
hasVariants: (doc) => doc.type === 'variable',
|
|
41
|
+
|
|
42
|
+
getType: (doc) => doc.type ?? 'simple',
|
|
43
|
+
|
|
44
|
+
getBarcode: (doc) => doc.barcode || undefined,
|
|
45
|
+
|
|
46
|
+
getCategoryNames: (doc) =>
|
|
47
|
+
(doc.categories ?? []).map((cat: any) => cat.name).filter(Boolean),
|
|
48
|
+
};
|