@webbycrown/webbycommerce 1.2.0 → 2.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.
Files changed (162) hide show
  1. package/README.md +26 -3
  2. package/admin/app.js +3 -0
  3. package/admin/jsconfig.json +20 -0
  4. package/admin/src/components/ApiCollectionsContent.jsx +4626 -0
  5. package/admin/src/components/CompareContent.jsx +300 -0
  6. package/admin/src/components/ConfigureContent.jsx +407 -0
  7. package/admin/src/components/Initializer.jsx +64 -0
  8. package/admin/src/components/LoginRegisterContent.jsx +280 -0
  9. package/admin/src/components/PluginIcon.jsx +6 -0
  10. package/admin/src/components/ShippingTypeContent.jsx +230 -0
  11. package/admin/src/components/SmtpContent.jsx +316 -0
  12. package/admin/src/components/WishlistContent.jsx +273 -0
  13. package/admin/src/index.js +81 -0
  14. package/admin/src/pages/ApiCollections.jsx +169 -0
  15. package/admin/src/pages/Configure.jsx +55 -0
  16. package/admin/src/pages/Settings.jsx +93 -0
  17. package/admin/src/pluginId.js +4 -0
  18. package/{dist/_chunks/en-CiQ97iC8.js → admin/src/translations/en.json} +712 -574
  19. package/bin/setup.js +50 -3
  20. package/package.json +14 -13
  21. package/server/bootstrap.js +3 -0
  22. package/server/register.js +3 -0
  23. package/server/src/bootstrap.js +3826 -0
  24. package/server/src/components/content-block.json +37 -0
  25. package/server/src/components/shipping-zone-location.json +27 -0
  26. package/server/src/config/index.js +7 -0
  27. package/server/src/content-types/address/index.js +7 -0
  28. package/server/src/content-types/address/schema.json +74 -0
  29. package/server/src/content-types/cart/index.js +61 -0
  30. package/server/src/content-types/cart-item/index.js +79 -0
  31. package/server/src/content-types/compare.js +73 -0
  32. package/server/src/content-types/coupon/index.js +7 -0
  33. package/server/src/content-types/coupon/schema.json +67 -0
  34. package/server/src/content-types/index.js +42 -0
  35. package/server/src/content-types/order/index.js +7 -0
  36. package/server/src/content-types/order/schema.json +121 -0
  37. package/server/src/content-types/payment-transaction/index.js +7 -0
  38. package/server/src/content-types/payment-transaction/schema.json +73 -0
  39. package/server/src/content-types/product/index.js +7 -0
  40. package/server/src/content-types/product/schema.json +104 -0
  41. package/server/src/content-types/product-attribute/index.js +7 -0
  42. package/server/src/content-types/product-attribute/schema.json +80 -0
  43. package/server/src/content-types/product-attribute-value/index.js +7 -0
  44. package/server/src/content-types/product-attribute-value/schema.json +52 -0
  45. package/server/src/content-types/product-category/index.js +7 -0
  46. package/server/src/content-types/product-category/schema.json +54 -0
  47. package/server/src/content-types/product-tag/index.js +7 -0
  48. package/server/src/content-types/product-tag/schema.json +38 -0
  49. package/server/src/content-types/product-variation/index.js +7 -0
  50. package/server/src/content-types/product-variation/schema.json +74 -0
  51. package/server/src/content-types/shipping-method/index.js +7 -0
  52. package/server/src/content-types/shipping-method/schema.json +91 -0
  53. package/server/src/content-types/shipping-rate/index.js +7 -0
  54. package/server/src/content-types/shipping-rate/schema.json +73 -0
  55. package/server/src/content-types/shipping-rule/index.js +7 -0
  56. package/server/src/content-types/shipping-rule/schema.json +84 -0
  57. package/server/src/content-types/shipping-zone/index.js +7 -0
  58. package/server/src/content-types/shipping-zone/schema.json +57 -0
  59. package/server/src/content-types/wishlist.js +66 -0
  60. package/server/src/controllers/address.js +374 -0
  61. package/server/src/controllers/auth.js +1409 -0
  62. package/server/src/controllers/cart.js +337 -0
  63. package/server/src/controllers/category.js +388 -0
  64. package/server/src/controllers/compare.js +246 -0
  65. package/server/src/controllers/controller.js +168 -0
  66. package/server/src/controllers/ecommerce.js +20 -0
  67. package/server/src/controllers/index.js +34 -0
  68. package/server/src/controllers/order.js +1100 -0
  69. package/server/src/controllers/payment.js +243 -0
  70. package/server/src/controllers/product.js +1006 -0
  71. package/server/src/controllers/productTag.js +370 -0
  72. package/server/src/controllers/productVariation.js +181 -0
  73. package/server/src/controllers/shipping.js +1046 -0
  74. package/server/src/controllers/wishlist.js +332 -0
  75. package/server/src/destroy.js +6 -0
  76. package/server/src/index.js +26 -0
  77. package/server/src/middlewares/index.js +4 -0
  78. package/server/src/policies/index.js +4 -0
  79. package/server/src/register.js +67 -0
  80. package/server/src/routes/index.js +1130 -0
  81. package/server/src/services/cart.js +531 -0
  82. package/server/src/services/compare.js +300 -0
  83. package/server/src/services/index.js +16 -0
  84. package/server/src/services/service.js +19 -0
  85. package/server/src/services/shipping.js +513 -0
  86. package/server/src/services/wishlist.js +238 -0
  87. package/server/src/utils/check-ecommerce-permission.js +204 -0
  88. package/server/src/utils/extend-user-schema.js +161 -0
  89. package/server/src/utils/seed-data.js +639 -0
  90. package/server/src/utils/send-email.js +98 -0
  91. package/strapi-server.js +1 -6
  92. package/dist/_chunks/Settings-DZXAkI24.js +0 -31539
  93. package/dist/_chunks/Settings-yLx-YvVy.mjs +0 -31520
  94. package/dist/_chunks/en-DE15m4xZ.mjs +0 -574
  95. package/dist/_chunks/index-CXGrFKp6.mjs +0 -128
  96. package/dist/_chunks/index-DgocXUgC.js +0 -127
  97. package/dist/admin/index.js +0 -3
  98. package/dist/admin/index.mjs +0 -4
  99. package/dist/robots.txt +0 -3
  100. package/dist/server/index.js +0 -27078
  101. package/dist/uploads/.gitkeep +0 -0
  102. package/dist/uploads/accessories_category_2a5631094b.jpeg +0 -0
  103. package/dist/uploads/beauty_personal_care_category_57f8a8f1e3.jpeg +0 -0
  104. package/dist/uploads/books_category_a9a253eada.jpeg +0 -0
  105. package/dist/uploads/classic_cotton_tshirt_1_cd713425f6.png +0 -0
  106. package/dist/uploads/clothing_category_d5c60ef07b.jpeg +0 -0
  107. package/dist/uploads/daviddoe_strapi_adbcd41787.jpeg +0 -0
  108. package/dist/uploads/electronics_category_fc3e5ef571.jpeg +0 -0
  109. package/dist/uploads/ergonomic_office_chair_1_c751cffb07.png +0 -0
  110. package/dist/uploads/home_garden_category_4f6eb3f8d6.jpeg +0 -0
  111. package/dist/uploads/istockphoto_1188462138_612x612_11f295b9c0.jpg +0 -0
  112. package/dist/uploads/istockphoto_1188462138_612x612_396fb272fd.jpg +0 -0
  113. package/dist/uploads/large_daviddoe_strapi_adbcd41787.jpeg +0 -0
  114. package/dist/uploads/leather_travel_backpack_1_238bc1ae4d.png +0 -0
  115. package/dist/uploads/mechanical_keyboard_pro_1_0cd391a6ac.png +0 -0
  116. package/dist/uploads/medium_classic_cotton_tshirt_1_cd713425f6.png +0 -0
  117. package/dist/uploads/medium_daviddoe_strapi_adbcd41787.jpeg +0 -0
  118. package/dist/uploads/medium_ergonomic_office_chair_1_c751cffb07.png +0 -0
  119. package/dist/uploads/medium_leather_travel_backpack_1_238bc1ae4d.png +0 -0
  120. package/dist/uploads/medium_mechanical_keyboard_pro_1_0cd391a6ac.png +0 -0
  121. package/dist/uploads/medium_smart_watch_series_5_1_cdc2511fb7.png +0 -0
  122. package/dist/uploads/medium_smartphone_x_pro_1_c3f0cbd080.png +0 -0
  123. package/dist/uploads/medium_the_great_gatsby_special_1_2e7c76d997.png +0 -0
  124. package/dist/uploads/medium_wireless_headphones_1_fa75cd50c3.png +0 -0
  125. package/dist/uploads/medium_yoga_mat_premium_1_01f9a3b5fa.png +0 -0
  126. package/dist/uploads/predictive_maintenance_icons_industry_automation_600nw_2685943461_e18a8aa3b0.webp +0 -0
  127. package/dist/uploads/small_classic_cotton_tshirt_1_cd713425f6.png +0 -0
  128. package/dist/uploads/small_daviddoe_strapi_adbcd41787.jpeg +0 -0
  129. package/dist/uploads/small_ergonomic_office_chair_1_c751cffb07.png +0 -0
  130. package/dist/uploads/small_leather_travel_backpack_1_238bc1ae4d.png +0 -0
  131. package/dist/uploads/small_mechanical_keyboard_pro_1_0cd391a6ac.png +0 -0
  132. package/dist/uploads/small_smart_watch_series_5_1_cdc2511fb7.png +0 -0
  133. package/dist/uploads/small_smartphone_x_pro_1_c3f0cbd080.png +0 -0
  134. package/dist/uploads/small_the_great_gatsby_special_1_2e7c76d997.png +0 -0
  135. package/dist/uploads/small_wireless_headphones_1_fa75cd50c3.png +0 -0
  136. package/dist/uploads/small_yoga_mat_premium_1_01f9a3b5fa.png +0 -0
  137. package/dist/uploads/smart_watch_series_5_1_cdc2511fb7.png +0 -0
  138. package/dist/uploads/smartphone_x_pro_1_c3f0cbd080.png +0 -0
  139. package/dist/uploads/the_great_gatsby_special_1_2e7c76d997.png +0 -0
  140. package/dist/uploads/thumbnail_accessories_category_2a5631094b.jpeg +0 -0
  141. package/dist/uploads/thumbnail_beauty_personal_care_category_57f8a8f1e3.jpeg +0 -0
  142. package/dist/uploads/thumbnail_books_category_a9a253eada.jpeg +0 -0
  143. package/dist/uploads/thumbnail_classic_cotton_tshirt_1_cd713425f6.png +0 -0
  144. package/dist/uploads/thumbnail_clothing_category_d5c60ef07b.jpeg +0 -0
  145. package/dist/uploads/thumbnail_daviddoe_strapi_adbcd41787.jpeg +0 -0
  146. package/dist/uploads/thumbnail_electronics_category_fc3e5ef571.jpeg +0 -0
  147. package/dist/uploads/thumbnail_ergonomic_office_chair_1_c751cffb07.png +0 -0
  148. package/dist/uploads/thumbnail_home_garden_category_4f6eb3f8d6.jpeg +0 -0
  149. package/dist/uploads/thumbnail_istockphoto_1188462138_612x612_11f295b9c0.jpg +0 -0
  150. package/dist/uploads/thumbnail_istockphoto_1188462138_612x612_396fb272fd.jpg +0 -0
  151. package/dist/uploads/thumbnail_leather_travel_backpack_1_238bc1ae4d.png +0 -0
  152. package/dist/uploads/thumbnail_mechanical_keyboard_pro_1_0cd391a6ac.png +0 -0
  153. package/dist/uploads/thumbnail_predictive_maintenance_icons_industry_automation_600nw_2685943461_e18a8aa3b0.webp +0 -0
  154. package/dist/uploads/thumbnail_smart_watch_series_5_1_cdc2511fb7.png +0 -0
  155. package/dist/uploads/thumbnail_smartphone_x_pro_1_c3f0cbd080.png +0 -0
  156. package/dist/uploads/thumbnail_the_great_gatsby_special_1_2e7c76d997.png +0 -0
  157. package/dist/uploads/thumbnail_wireless_headphones_1_fa75cd50c3.png +0 -0
  158. package/dist/uploads/thumbnail_yoga_mat_premium_1_01f9a3b5fa.png +0 -0
  159. package/dist/uploads/webby-commerce.png +0 -0
  160. package/dist/uploads/wireless_headphones_1_fa75cd50c3.png +0 -0
  161. package/dist/uploads/yoga_mat_premium_1_01f9a3b5fa.png +0 -0
  162. /package/{dist → server/src}/data/demo-data.json +0 -0
@@ -0,0 +1,1006 @@
1
+ 'use strict';
2
+
3
+ const PLUGIN_ID = 'webbycommerce';
4
+ const { ensureEcommercePermission } = require('../utils/check-ecommerce-permission');
5
+
6
+ module.exports = {
7
+ /**
8
+ * Get all products
9
+ */
10
+ async getProducts(ctx) {
11
+ try {
12
+ // Check ecommerce permission
13
+ if (!(await ensureEcommercePermission(ctx))) {
14
+ return;
15
+ }
16
+
17
+ const { product_category, tag, search, limit, start = 0, getAll } = ctx.query;
18
+
19
+ // Strapi v5 stores drafts + published versions as separate rows (document model).
20
+ // For storefront APIs we only want published records, otherwise you can see duplicates.
21
+ const where = { publishedAt: { $notNull: true } };
22
+
23
+ if (product_category) {
24
+ where.product_categories = { id: product_category };
25
+ }
26
+
27
+ if (tag) {
28
+ where.tags = { id: tag };
29
+ }
30
+
31
+ if (search) {
32
+ where.name = { $containsi: search };
33
+ }
34
+
35
+ const total = await strapi.db.query('plugin::webbycommerce.product').count({ where });
36
+
37
+ // If getAll=true or limit is not provided, return all products without pagination
38
+ const shouldGetAll = getAll === 'true' || getAll === true || limit === undefined || limit === null;
39
+
40
+ const queryOptions = {
41
+ where,
42
+ orderBy: { createdAt: 'desc' },
43
+ populate: {
44
+ product_categories: true,
45
+ tags: true,
46
+ images: true,
47
+ variations: {
48
+ populate: ['attributes', 'attributeValues'],
49
+ },
50
+ },
51
+ };
52
+
53
+ // Only add pagination if getAll is false and limit is provided
54
+ if (!shouldGetAll && limit) {
55
+ queryOptions.limit = parseInt(limit, 10);
56
+ queryOptions.start = parseInt(start, 10);
57
+ }
58
+
59
+ const products = await strapi.db.query('plugin::webbycommerce.product').findMany(queryOptions);
60
+
61
+ const responseMeta = shouldGetAll
62
+ ? { total, returned: products.length, pagination: false }
63
+ : {
64
+ total,
65
+ limit: parseInt(limit, 10),
66
+ start: parseInt(start, 10),
67
+ pagination: true,
68
+ page: Math.floor(parseInt(start, 10) / parseInt(limit, 10)) + 1,
69
+ pageCount: Math.ceil(total / parseInt(limit, 10))
70
+ };
71
+
72
+ ctx.send({ data: products, meta: responseMeta });
73
+ } catch (error) {
74
+ strapi.log.error(`[${PLUGIN_ID}] Error in getProducts:`, error);
75
+ ctx.internalServerError('Failed to fetch products. Please try again.');
76
+ }
77
+ },
78
+
79
+ /**
80
+ * Get single product
81
+ */
82
+ async getProduct(ctx) {
83
+ try {
84
+ // Check ecommerce permission
85
+ if (!(await ensureEcommercePermission(ctx))) {
86
+ return;
87
+ }
88
+
89
+ const { id } = ctx.params;
90
+
91
+ const product = await strapi.db.query('plugin::webbycommerce.product').findOne({
92
+ where: { id, publishedAt: { $notNull: true } },
93
+ populate: ['product_categories', 'tags', 'images', 'variations'],
94
+ });
95
+
96
+ if (!product) {
97
+ return ctx.notFound('Product not found.');
98
+ }
99
+
100
+ ctx.send({ data: product });
101
+ } catch (error) {
102
+ strapi.log.error(`[${PLUGIN_ID}] Error in getProduct:`, error);
103
+ ctx.internalServerError('Failed to fetch product. Please try again.');
104
+ }
105
+ },
106
+
107
+ /**
108
+ * Get single product by slug
109
+ */
110
+ async getProductBySlug(ctx) {
111
+ try {
112
+ // Check ecommerce permission
113
+ if (!(await ensureEcommercePermission(ctx))) {
114
+ return;
115
+ }
116
+
117
+ const rawSlug = ctx.params?.slug;
118
+ const slug = typeof rawSlug === 'string' ? decodeURIComponent(rawSlug).trim() : '';
119
+
120
+ if (!slug) {
121
+ return ctx.badRequest('Slug is required.');
122
+ }
123
+
124
+ // Allow numeric slugs to still work like "get by id"
125
+ if (/^[0-9]+$/.test(slug)) {
126
+ const product = await strapi.db.query('plugin::webbycommerce.product').findOne({
127
+ where: { id: slug, publishedAt: { $notNull: true } },
128
+ populate: ['product_categories', 'tags', 'images', 'variations'],
129
+ });
130
+
131
+ if (!product) {
132
+ return ctx.notFound('Product not found.');
133
+ }
134
+
135
+ ctx.send({ data: product });
136
+ return;
137
+ }
138
+
139
+ // Strapi v5 can store draft + published versions as separate rows (document model).
140
+ // For storefront APIs we only want published records.
141
+ const results = await strapi.db.query('plugin::webbycommerce.product').findMany({
142
+ where: { slug, publishedAt: { $notNull: true } },
143
+ limit: 1,
144
+ orderBy: { publishedAt: 'desc', id: 'desc' },
145
+ populate: ['product_categories', 'tags', 'images', 'variations'],
146
+ });
147
+
148
+ const product = results?.[0];
149
+
150
+ if (!product) {
151
+ return ctx.notFound('Product not found.');
152
+ }
153
+
154
+ ctx.send({ data: product });
155
+ } catch (error) {
156
+ strapi.log.error(`[${PLUGIN_ID}] Error in getProductBySlug:`, error);
157
+ ctx.internalServerError('Failed to fetch product. Please try again.');
158
+ }
159
+ },
160
+
161
+ /**
162
+ * Create product
163
+ */
164
+ async createProduct(ctx) {
165
+ try {
166
+ const { name, description, price, sale_price, sku, slug, stock_quantity, stock_status, weight, dimensions, product_categories, tags, images } = ctx.request.body || {};
167
+
168
+ if (!name || price === undefined || price === null) {
169
+ return ctx.badRequest('Name and price are required.');
170
+ }
171
+
172
+ // Validate and parse numeric fields
173
+ const parsedPrice = parseFloat(price);
174
+ if (isNaN(parsedPrice) || parsedPrice < 0) {
175
+ return ctx.badRequest('Price must be a valid positive number.');
176
+ }
177
+
178
+ let parsedSalePrice = null;
179
+ if (sale_price !== undefined && sale_price !== null) {
180
+ parsedSalePrice = parseFloat(sale_price);
181
+ if (isNaN(parsedSalePrice) || parsedSalePrice < 0) {
182
+ return ctx.badRequest('Sale price must be a valid positive number.');
183
+ }
184
+ }
185
+
186
+ let parsedStockQuantity = 0;
187
+ if (stock_quantity !== undefined && stock_quantity !== null) {
188
+ parsedStockQuantity = parseInt(stock_quantity, 10);
189
+ if (isNaN(parsedStockQuantity) || parsedStockQuantity < 0) {
190
+ return ctx.badRequest('Stock quantity must be a valid non-negative integer.');
191
+ }
192
+ }
193
+
194
+ let parsedWeight = null;
195
+ if (weight !== undefined && weight !== null) {
196
+ parsedWeight = parseFloat(weight);
197
+ if (isNaN(parsedWeight) || parsedWeight < 0) {
198
+ return ctx.badRequest('Weight must be a valid positive number.');
199
+ }
200
+ }
201
+
202
+ const data = {
203
+ name,
204
+ description,
205
+ price: parsedPrice,
206
+ sale_price: parsedSalePrice,
207
+ sku,
208
+ slug,
209
+ stock_quantity: parsedStockQuantity,
210
+ stock_status: stock_status || 'in_stock',
211
+ weight: parsedWeight,
212
+ dimensions,
213
+ publishedAt: new Date(),
214
+ };
215
+
216
+ // Prepare relation IDs
217
+ let categoryIds = [];
218
+ if (product_categories && Array.isArray(product_categories) && product_categories.length > 0) {
219
+ categoryIds = product_categories.map(id =>
220
+ typeof id === 'object' && id.id ? id.id : id
221
+ );
222
+ }
223
+
224
+ let tagIds = [];
225
+ if (tags && Array.isArray(tags) && tags.length > 0) {
226
+ tagIds = tags.map(id =>
227
+ typeof id === 'object' && id.id ? id.id : id
228
+ );
229
+ }
230
+
231
+ let imageIds = [];
232
+ if (images && Array.isArray(images) && images.length > 0) {
233
+ imageIds = images.map(id =>
234
+ typeof id === 'object' && id.id ? id.id : id
235
+ );
236
+ }
237
+
238
+ // Use entityService for Strapi v5 document model compatibility
239
+ // This ensures products appear in the content manager
240
+ const product = await strapi.entityService.create('plugin::webbycommerce.product', {
241
+ data,
242
+ });
243
+
244
+ // Update product with relations to ensure they're properly linked in document model
245
+ const updateData = {};
246
+ if (categoryIds.length > 0) {
247
+ updateData.product_categories = categoryIds;
248
+ }
249
+ if (tagIds.length > 0) {
250
+ updateData.tags = tagIds;
251
+ }
252
+ if (imageIds.length > 0) {
253
+ updateData.images = imageIds;
254
+ }
255
+
256
+ let updatedProduct = product;
257
+ if (Object.keys(updateData).length > 0) {
258
+ updatedProduct = await strapi.entityService.update('plugin::webbycommerce.product', product.id, {
259
+ data: updateData,
260
+ });
261
+ }
262
+
263
+ // Re-fetch the product with relations populated using entityService
264
+ const populated = await strapi.entityService.findOne('plugin::webbycommerce.product', updatedProduct.id, {
265
+ populate: ['product_categories', 'tags', 'images', 'variations'],
266
+ });
267
+
268
+ ctx.send({ data: populated || updatedProduct });
269
+ } catch (error) {
270
+ strapi.log.error(`[${PLUGIN_ID}] Error in createProduct:`, error);
271
+ ctx.internalServerError('Failed to create product. Please try again.');
272
+ }
273
+ },
274
+
275
+ /**
276
+ * Create products in bulk
277
+ */
278
+ async createBulkProducts(ctx) {
279
+ try {
280
+ // Check ecommerce permission
281
+ if (!(await ensureEcommercePermission(ctx))) {
282
+ return;
283
+ }
284
+
285
+ const { products } = ctx.request.body || {};
286
+
287
+ if (!Array.isArray(products)) {
288
+ return ctx.badRequest('Products must be an array.');
289
+ }
290
+
291
+ if (products.length === 0) {
292
+ return ctx.badRequest('Products array cannot be empty.');
293
+ }
294
+
295
+ // Limit bulk operations to prevent performance issues
296
+ const MAX_BULK_SIZE = 100;
297
+ if (products.length > MAX_BULK_SIZE) {
298
+ return ctx.badRequest(`Cannot create more than ${MAX_BULK_SIZE} products at once.`);
299
+ }
300
+
301
+ // Helper function to build connect payloads
302
+ const buildConnect = (arr) => {
303
+ if (!arr) return undefined;
304
+ if (Array.isArray(arr) && arr.length > 0) {
305
+ if (typeof arr[0] === 'object') return arr;
306
+ return arr.map((id) => ({ id }));
307
+ }
308
+ return undefined;
309
+ };
310
+
311
+ // Helper function to validate and prepare a single product
312
+ const validateAndPrepareProduct = (productData, index) => {
313
+ const errors = [];
314
+
315
+ if (!productData.name || productData.price === undefined || productData.price === null) {
316
+ errors.push('Name and price are required.');
317
+ }
318
+
319
+ const parsedPrice = parseFloat(productData.price);
320
+ if (isNaN(parsedPrice) || parsedPrice < 0) {
321
+ errors.push('Price must be a valid positive number.');
322
+ }
323
+
324
+ let parsedSalePrice = null;
325
+ if (productData.sale_price !== undefined && productData.sale_price !== null) {
326
+ parsedSalePrice = parseFloat(productData.sale_price);
327
+ if (isNaN(parsedSalePrice) || parsedSalePrice < 0) {
328
+ errors.push('Sale price must be a valid positive number.');
329
+ }
330
+ }
331
+
332
+ let parsedStockQuantity = 0;
333
+ if (productData.stock_quantity !== undefined && productData.stock_quantity !== null) {
334
+ parsedStockQuantity = parseInt(productData.stock_quantity, 10);
335
+ if (isNaN(parsedStockQuantity) || parsedStockQuantity < 0) {
336
+ errors.push('Stock quantity must be a valid non-negative integer.');
337
+ }
338
+ }
339
+
340
+ let parsedWeight = null;
341
+ if (productData.weight !== undefined && productData.weight !== null) {
342
+ parsedWeight = parseFloat(productData.weight);
343
+ if (isNaN(parsedWeight) || parsedWeight < 0) {
344
+ errors.push('Weight must be a valid positive number.');
345
+ }
346
+ }
347
+
348
+ if (errors.length > 0) {
349
+ return { valid: false, errors, index };
350
+ }
351
+
352
+ const data = {
353
+ name: productData.name,
354
+ description: productData.description,
355
+ price: parsedPrice,
356
+ sale_price: parsedSalePrice,
357
+ sku: productData.sku,
358
+ slug: productData.slug,
359
+ stock_quantity: parsedStockQuantity,
360
+ stock_status: productData.stock_status || 'in_stock',
361
+ weight: parsedWeight,
362
+ dimensions: productData.dimensions,
363
+ publishedAt: new Date(),
364
+ };
365
+
366
+ // Prepare relation IDs separately
367
+ const relations = {
368
+ product_categories: [],
369
+ tags: [],
370
+ images: [],
371
+ };
372
+
373
+ if (productData.product_categories && Array.isArray(productData.product_categories) && productData.product_categories.length > 0) {
374
+ relations.product_categories = productData.product_categories.map(id =>
375
+ typeof id === 'object' && id.id ? id.id : id
376
+ );
377
+ }
378
+
379
+ if (productData.tags && Array.isArray(productData.tags) && productData.tags.length > 0) {
380
+ relations.tags = productData.tags.map(id =>
381
+ typeof id === 'object' && id.id ? id.id : id
382
+ );
383
+ }
384
+
385
+ if (productData.images && Array.isArray(productData.images) && productData.images.length > 0) {
386
+ relations.images = productData.images.map(id =>
387
+ typeof id === 'object' && id.id ? id.id : id
388
+ );
389
+ }
390
+
391
+ return { valid: true, data, relations, index };
392
+ };
393
+
394
+ const results = {
395
+ success: [],
396
+ failed: [],
397
+ summary: {
398
+ total: products.length,
399
+ successful: 0,
400
+ failed: 0,
401
+ },
402
+ };
403
+
404
+ // Process each product
405
+ for (let i = 0; i < products.length; i++) {
406
+ const productData = products[i];
407
+ const validation = validateAndPrepareProduct(productData, i);
408
+
409
+ if (!validation.valid) {
410
+ results.failed.push({
411
+ index: i,
412
+ product: productData,
413
+ errors: validation.errors,
414
+ });
415
+ results.summary.failed++;
416
+ continue;
417
+ }
418
+
419
+ try {
420
+ // Use entityService for Strapi v5 document model compatibility
421
+ // This ensures products appear in the content manager
422
+ const product = await strapi.entityService.create('plugin::webbycommerce.product', {
423
+ data: validation.data,
424
+ });
425
+
426
+ // Update product with relations to ensure they're properly linked in document model
427
+ const updateData = {};
428
+ if (validation.relations.product_categories.length > 0) {
429
+ updateData.product_categories = validation.relations.product_categories;
430
+ }
431
+ if (validation.relations.tags.length > 0) {
432
+ updateData.tags = validation.relations.tags;
433
+ }
434
+ if (validation.relations.images.length > 0) {
435
+ updateData.images = validation.relations.images;
436
+ }
437
+
438
+ let updatedProduct = product;
439
+ if (Object.keys(updateData).length > 0) {
440
+ updatedProduct = await strapi.entityService.update('plugin::webbycommerce.product', product.id, {
441
+ data: updateData,
442
+ });
443
+ }
444
+
445
+ // Re-fetch with populated relations using entityService
446
+ const populated = await strapi.entityService.findOne('plugin::webbycommerce.product', updatedProduct.id, {
447
+ populate: ['product_categories', 'tags', 'images', 'variations'],
448
+ });
449
+
450
+ results.success.push({
451
+ index: i,
452
+ product: populated || updatedProduct,
453
+ });
454
+ results.summary.successful++;
455
+ } catch (error) {
456
+ strapi.log.error(`[${PLUGIN_ID}] Error creating product at index ${i}:`, error);
457
+ results.failed.push({
458
+ index: i,
459
+ product: productData,
460
+ errors: [error.message || 'Failed to create product.'],
461
+ });
462
+ results.summary.failed++;
463
+ }
464
+ }
465
+
466
+ // Return 207 Multi-Status if there are mixed results, 200 if all succeeded, 400 if all failed
467
+ const statusCode = results.summary.failed === 0 ? 200 : results.summary.successful === 0 ? 400 : 207;
468
+ ctx.status = statusCode;
469
+ ctx.send({ data: results });
470
+ } catch (error) {
471
+ strapi.log.error(`[${PLUGIN_ID}] Error in createBulkProducts:`, error);
472
+ ctx.internalServerError('Failed to create products in bulk. Please try again.');
473
+ }
474
+ },
475
+
476
+ /**
477
+ * Update product
478
+ */
479
+ async updateProduct(ctx) {
480
+ try {
481
+ // Check ecommerce permission
482
+ if (!(await ensureEcommercePermission(ctx))) {
483
+ return;
484
+ }
485
+
486
+ const { id } = ctx.params;
487
+
488
+ const body = ctx.request.body || {};
489
+
490
+ const updateData = { ...body };
491
+
492
+ // Convert relation id arrays to connect format for update
493
+ const buildConnectForUpdate = (key, target) => {
494
+ const val = body[key];
495
+ if (val === undefined) return;
496
+ if (Array.isArray(val) && val.length > 0) {
497
+ if (typeof val[0] === 'object') {
498
+ updateData[key] = { connect: val };
499
+ } else {
500
+ updateData[key] = { connect: val.map((id) => ({ id })) };
501
+ }
502
+ } else if (Array.isArray(val) && val.length === 0) {
503
+ // empty array -> disconnect all
504
+ updateData[key] = { disconnect: [] };
505
+ }
506
+ };
507
+
508
+ buildConnectForUpdate('product_categories');
509
+ buildConnectForUpdate('tags');
510
+ buildConnectForUpdate('images');
511
+
512
+ const product = await strapi.db.query('plugin::webbycommerce.product').update({
513
+ where: { id },
514
+ data: updateData,
515
+ });
516
+
517
+ if (!product) {
518
+ return ctx.notFound('Product not found.');
519
+ }
520
+
521
+ // Re-fetch with populated relations
522
+ const populatedUpdated = await strapi.db.query('plugin::webbycommerce.product').findOne({
523
+ where: { id: product.id },
524
+ populate: ['product_categories', 'tags', 'images', 'variations'],
525
+ });
526
+
527
+ ctx.send({ data: populatedUpdated || product });
528
+ } catch (error) {
529
+ strapi.log.error(`[${PLUGIN_ID}] Error in updateProduct:`, error);
530
+ ctx.internalServerError('Failed to update product. Please try again.');
531
+ }
532
+ },
533
+
534
+ /**
535
+ * Delete product
536
+ */
537
+ async deleteProduct(ctx) {
538
+ try {
539
+ // Check ecommerce permission
540
+ if (!(await ensureEcommercePermission(ctx))) {
541
+ return;
542
+ }
543
+
544
+ const { id } = ctx.params;
545
+
546
+ const product = await strapi.db.query('plugin::webbycommerce.product').delete({
547
+ where: { id },
548
+ });
549
+
550
+ if (!product) {
551
+ return ctx.notFound('Product not found.');
552
+ }
553
+
554
+ ctx.send({ data: product });
555
+ } catch (error) {
556
+ strapi.log.error(`[${PLUGIN_ID}] Error in deleteProduct:`, error);
557
+ ctx.internalServerError('Failed to delete product. Please try again.');
558
+ }
559
+ },
560
+
561
+ /**
562
+ * Get related products
563
+ */
564
+ async getRelatedProducts(ctx) {
565
+ try {
566
+ // Check ecommerce permission
567
+ if (!(await ensureEcommercePermission(ctx))) {
568
+ return;
569
+ }
570
+
571
+ const { id } = ctx.params;
572
+ const { limit = 4 } = ctx.query;
573
+
574
+ // Get the current product to find related products
575
+ const product = await strapi.db.query('plugin::webbycommerce.product').findOne({
576
+ where: { id },
577
+ populate: ['product_categories', 'tags'],
578
+ });
579
+
580
+ if (!product) {
581
+ return ctx.notFound('Product not found.');
582
+ }
583
+
584
+ const where = {
585
+ id: { $ne: id }, // Exclude current product
586
+ publishedAt: { $notNull: true } // Only published products
587
+ };
588
+
589
+ // Find products in same categories or with same tags
590
+ const categoryIds = product.product_categories?.map(cat => cat.id) || [];
591
+ const tagIds = product.tags?.map(tag => tag.id) || [];
592
+
593
+ if (categoryIds.length > 0 || tagIds.length > 0) {
594
+ where.$or = [];
595
+ if (categoryIds.length > 0) {
596
+ where.$or.push({ product_categories: { id: { $in: categoryIds } } });
597
+ }
598
+ if (tagIds.length > 0) {
599
+ where.$or.push({ tags: { id: { $in: tagIds } } });
600
+ }
601
+ }
602
+
603
+ const relatedProducts = await strapi.db.query('plugin::webbycommerce.product').findMany({
604
+ where,
605
+ limit: parseInt(limit, 10),
606
+ orderBy: { createdAt: 'desc' },
607
+ populate: ['product_categories', 'tags', 'images', 'variations'],
608
+ });
609
+
610
+ ctx.send({ data: relatedProducts });
611
+ } catch (error) {
612
+ strapi.log.error(`[${PLUGIN_ID}] Error in getRelatedProducts:`, error);
613
+ ctx.internalServerError('Failed to fetch related products. Please try again.');
614
+ }
615
+ },
616
+
617
+ /**
618
+ * Get product categories
619
+ */
620
+ async getCategories(ctx) {
621
+ try {
622
+ // Check ecommerce permission
623
+ if (!(await ensureEcommercePermission(ctx))) {
624
+ return;
625
+ }
626
+
627
+ const { limit = 20, start = 0, parent } = ctx.query;
628
+
629
+ const where = { is_active: true };
630
+
631
+ if (parent !== undefined) {
632
+ if (parent === 'null' || parent === '') {
633
+ where.parent = null; // Root categories
634
+ } else {
635
+ where.parent = { id: parent };
636
+ }
637
+ }
638
+
639
+ const categories = await strapi.db.query('plugin::webbycommerce.product-category').findMany({
640
+ where,
641
+ limit: parseInt(limit, 10),
642
+ start: parseInt(start, 10),
643
+ orderBy: { sort_order: 'asc' },
644
+ populate: ['parent', 'children'],
645
+ });
646
+
647
+ const total = await strapi.db.query('plugin::webbycommerce.product-category').count({ where });
648
+
649
+ ctx.send({
650
+ data: categories,
651
+ meta: {
652
+ total,
653
+ limit: parseInt(limit, 10),
654
+ start: parseInt(start, 10)
655
+ }
656
+ });
657
+ } catch (error) {
658
+ strapi.log.error(`[${PLUGIN_ID}] Error in getCategories:`, error);
659
+ ctx.internalServerError('Failed to fetch categories. Please try again.');
660
+ }
661
+ },
662
+
663
+ /**
664
+ * Get product tags
665
+ */
666
+ async getTags(ctx) {
667
+ try {
668
+ // Check ecommerce permission
669
+ if (!(await ensureEcommercePermission(ctx))) {
670
+ return;
671
+ }
672
+
673
+ const { limit = 20, start = 0, search } = ctx.query;
674
+
675
+ // Strapi v5 stores drafts + published versions as separate rows (document model).
676
+ // For storefront APIs we only want published records, otherwise you can see duplicates.
677
+ const where = { publishedAt: { $notNull: true } };
678
+
679
+ if (search) {
680
+ where.name = { $containsi: search };
681
+ }
682
+
683
+ const tags = await strapi.db.query('plugin::webbycommerce.product-tag').findMany({
684
+ where,
685
+ limit: parseInt(limit, 10),
686
+ start: parseInt(start, 10),
687
+ orderBy: { name: 'asc' },
688
+ });
689
+
690
+ const total = await strapi.db.query('plugin::webbycommerce.product-tag').count({ where });
691
+
692
+ ctx.send({
693
+ data: tags,
694
+ meta: {
695
+ total,
696
+ limit: parseInt(limit, 10),
697
+ start: parseInt(start, 10)
698
+ }
699
+ });
700
+ } catch (error) {
701
+ strapi.log.error(`[${PLUGIN_ID}] Error in getTags:`, error);
702
+ ctx.internalServerError('Failed to fetch tags. Please try again.');
703
+ }
704
+ },
705
+
706
+ /**
707
+ * Get product attributes
708
+ */
709
+ async getAttributes(ctx) {
710
+ try {
711
+ // Check ecommerce permission
712
+ if (!(await ensureEcommercePermission(ctx))) {
713
+ return;
714
+ }
715
+
716
+ const { limit = 20, start = 0, is_variation } = ctx.query;
717
+
718
+ const where = {};
719
+
720
+ if (is_variation !== undefined) {
721
+ where.is_variation = is_variation === 'true';
722
+ }
723
+
724
+ const attributes = await strapi.db.query('plugin::webbycommerce.product-attribute').findMany({
725
+ where,
726
+ limit: parseInt(limit, 10),
727
+ start: parseInt(start, 10),
728
+ orderBy: { sort_order: 'asc' },
729
+ populate: ['product_attribute_values'],
730
+ });
731
+
732
+ const total = await strapi.db.query('plugin::webbycommerce.product-attribute').count({ where });
733
+
734
+ ctx.send({
735
+ data: attributes,
736
+ meta: {
737
+ total,
738
+ limit: parseInt(limit, 10),
739
+ start: parseInt(start, 10)
740
+ }
741
+ });
742
+ } catch (error) {
743
+ strapi.log.error(`[${PLUGIN_ID}] Error in getAttributes:`, error);
744
+ ctx.internalServerError('Failed to fetch attributes. Please try again.');
745
+ }
746
+ },
747
+
748
+ // Standard Strapi controller methods for content manager
749
+ async find(ctx) {
750
+ try {
751
+ const { product_category, tag, search, limit = 25, start = 0, sort = 'createdAt:desc' } = ctx.query;
752
+
753
+ const where = { publishedAt: { $notNull: true } }; // Only published products
754
+
755
+ if (product_category) {
756
+ where.product_categories = { id: product_category };
757
+ }
758
+
759
+ if (tag) {
760
+ where.tags = { id: tag };
761
+ }
762
+
763
+ if (search) {
764
+ where.name = { $containsi: search };
765
+ }
766
+
767
+ const products = await strapi.db.query('plugin::webbycommerce.product').findMany({
768
+ where,
769
+ limit: parseInt(limit, 10),
770
+ start: parseInt(start, 10),
771
+ orderBy: sort.split(':').reduce((acc, val, i) => {
772
+ if (i === 0) acc[val] = 'asc';
773
+ else acc[val] = 'desc';
774
+ return acc;
775
+ }, {}),
776
+ populate: ['product_categories', 'tags', 'images', 'variations'],
777
+ });
778
+
779
+ const total = await strapi.db.query('plugin::webbycommerce.product').count({ where });
780
+
781
+ ctx.send({
782
+ data: products,
783
+ meta: {
784
+ pagination: {
785
+ page: Math.floor(parseInt(start, 10) / parseInt(limit, 10)) + 1,
786
+ pageSize: parseInt(limit, 10),
787
+ pageCount: Math.ceil(total / parseInt(limit, 10)),
788
+ total,
789
+ },
790
+ },
791
+ });
792
+ } catch (error) {
793
+ strapi.log.error(`[${PLUGIN_ID}] Error in find:`, error);
794
+ ctx.internalServerError('Failed to fetch products.');
795
+ }
796
+ },
797
+
798
+ async findOne(ctx) {
799
+ try {
800
+ const { id } = ctx.params;
801
+
802
+ const product = await strapi.db.query('plugin::webbycommerce.product').findOne({
803
+ where: { id, publishedAt: { $notNull: true } },
804
+ populate: ['product_categories', 'tags', 'images', 'variations'],
805
+ });
806
+
807
+ if (!product) {
808
+ return ctx.notFound('Product not found.');
809
+ }
810
+
811
+ ctx.send({ data: product });
812
+ } catch (error) {
813
+ strapi.log.error(`[${PLUGIN_ID}] Error in findOne:`, error);
814
+ ctx.internalServerError('Failed to fetch product.');
815
+ }
816
+ },
817
+
818
+ async create(ctx) {
819
+ try {
820
+ const data = ctx.request.body.data || ctx.request.body;
821
+
822
+ // Validate required fields
823
+ if (!data.name || data.price === undefined || data.price === null) {
824
+ return ctx.badRequest('Name and price are required.');
825
+ }
826
+
827
+ // Validate and parse numeric fields
828
+ const parsedPrice = parseFloat(data.price);
829
+ if (isNaN(parsedPrice) || parsedPrice < 0) {
830
+ return ctx.badRequest('Price must be a valid positive number.');
831
+ }
832
+ data.price = parsedPrice;
833
+
834
+ if (data.sale_price !== undefined && data.sale_price !== null) {
835
+ const parsedSalePrice = parseFloat(data.sale_price);
836
+ if (isNaN(parsedSalePrice) || parsedSalePrice < 0) {
837
+ return ctx.badRequest('Sale price must be a valid positive number.');
838
+ }
839
+ data.sale_price = parsedSalePrice;
840
+ }
841
+
842
+ if (data.stock_quantity !== undefined && data.stock_quantity !== null) {
843
+ const parsedStockQuantity = parseInt(data.stock_quantity, 10);
844
+ if (isNaN(parsedStockQuantity) || parsedStockQuantity < 0) {
845
+ return ctx.badRequest('Stock quantity must be a valid non-negative integer.');
846
+ }
847
+ data.stock_quantity = parsedStockQuantity;
848
+ }
849
+
850
+ if (data.weight !== undefined && data.weight !== null) {
851
+ const parsedWeight = parseFloat(data.weight);
852
+ if (isNaN(parsedWeight) || parsedWeight < 0) {
853
+ return ctx.badRequest('Weight must be a valid positive number.');
854
+ }
855
+ data.weight = parsedWeight;
856
+ }
857
+
858
+ // Set publishedAt if not provided
859
+ if (!data.publishedAt) {
860
+ data.publishedAt = new Date();
861
+ }
862
+
863
+ // Extract relations before creating
864
+ const relations = {
865
+ product_categories: data.product_categories || [],
866
+ tags: data.tags || [],
867
+ images: data.images || [],
868
+ };
869
+
870
+ // Remove relations from data object for initial creation
871
+ delete data.product_categories;
872
+ delete data.tags;
873
+ delete data.images;
874
+
875
+ // Normalize relation IDs
876
+ if (Array.isArray(relations.product_categories) && relations.product_categories.length > 0) {
877
+ relations.product_categories = relations.product_categories.map(id =>
878
+ typeof id === 'object' && id.id ? id.id : id
879
+ );
880
+ }
881
+ if (Array.isArray(relations.tags) && relations.tags.length > 0) {
882
+ relations.tags = relations.tags.map(id =>
883
+ typeof id === 'object' && id.id ? id.id : id
884
+ );
885
+ }
886
+ if (Array.isArray(relations.images) && relations.images.length > 0) {
887
+ relations.images = relations.images.map(id =>
888
+ typeof id === 'object' && id.id ? id.id : id
889
+ );
890
+ }
891
+
892
+ // Use entityService for Strapi v5 document model compatibility
893
+ const product = await strapi.entityService.create('plugin::webbycommerce.product', {
894
+ data,
895
+ });
896
+
897
+ // Update product with relations to ensure they're properly linked in document model
898
+ const updateData = {};
899
+ if (relations.product_categories.length > 0) {
900
+ updateData.product_categories = relations.product_categories;
901
+ }
902
+ if (relations.tags.length > 0) {
903
+ updateData.tags = relations.tags;
904
+ }
905
+ if (relations.images.length > 0) {
906
+ updateData.images = relations.images;
907
+ }
908
+
909
+ let updatedProduct = product;
910
+ if (Object.keys(updateData).length > 0) {
911
+ updatedProduct = await strapi.entityService.update('plugin::webbycommerce.product', product.id, {
912
+ data: updateData,
913
+ });
914
+ }
915
+
916
+ // Populate relations for response
917
+ const populated = await strapi.entityService.findOne('plugin::webbycommerce.product', updatedProduct.id, {
918
+ populate: ['product_categories', 'tags', 'images', 'variations'],
919
+ });
920
+
921
+ ctx.send({ data: populated || updatedProduct });
922
+ } catch (error) {
923
+ strapi.log.error(`[${PLUGIN_ID}] Error in create:`, error);
924
+ ctx.internalServerError('Failed to create product.');
925
+ }
926
+ },
927
+
928
+ async update(ctx) {
929
+ try {
930
+ const { id } = ctx.params;
931
+ const data = ctx.request.body.data || ctx.request.body;
932
+
933
+ // Validate numeric fields if they are being updated
934
+ if (data.price !== undefined && data.price !== null) {
935
+ const parsedPrice = parseFloat(data.price);
936
+ if (isNaN(parsedPrice) || parsedPrice < 0) {
937
+ return ctx.badRequest('Price must be a valid positive number.');
938
+ }
939
+ data.price = parsedPrice;
940
+ }
941
+
942
+ if (data.sale_price !== undefined && data.sale_price !== null) {
943
+ const parsedSalePrice = parseFloat(data.sale_price);
944
+ if (isNaN(parsedSalePrice) || parsedSalePrice < 0) {
945
+ return ctx.badRequest('Sale price must be a valid positive number.');
946
+ }
947
+ data.sale_price = parsedSalePrice;
948
+ }
949
+
950
+ if (data.stock_quantity !== undefined && data.stock_quantity !== null) {
951
+ const parsedStockQuantity = parseInt(data.stock_quantity, 10);
952
+ if (isNaN(parsedStockQuantity) || parsedStockQuantity < 0) {
953
+ return ctx.badRequest('Stock quantity must be a valid non-negative integer.');
954
+ }
955
+ data.stock_quantity = parsedStockQuantity;
956
+ }
957
+
958
+ if (data.weight !== undefined && data.weight !== null) {
959
+ const parsedWeight = parseFloat(data.weight);
960
+ if (isNaN(parsedWeight) || parsedWeight < 0) {
961
+ return ctx.badRequest('Weight must be a valid positive number.');
962
+ }
963
+ data.weight = parsedWeight;
964
+ }
965
+
966
+ const product = await strapi.db.query('plugin::webbycommerce.product').update({
967
+ where: { id },
968
+ data,
969
+ });
970
+
971
+ if (!product) {
972
+ return ctx.notFound('Product not found.');
973
+ }
974
+
975
+ // Populate relations for response
976
+ const populated = await strapi.db.query('plugin::webbycommerce.product').findOne({
977
+ where: { id: product.id },
978
+ populate: ['product_categories', 'tags', 'images', 'variations'],
979
+ });
980
+
981
+ ctx.send({ data: populated || product });
982
+ } catch (error) {
983
+ strapi.log.error(`[${PLUGIN_ID}] Error in update:`, error);
984
+ ctx.internalServerError('Failed to update product.');
985
+ }
986
+ },
987
+
988
+ async delete(ctx) {
989
+ try {
990
+ const { id } = ctx.params;
991
+
992
+ const product = await strapi.db.query('plugin::webbycommerce.product').delete({
993
+ where: { id },
994
+ });
995
+
996
+ if (!product) {
997
+ return ctx.notFound('Product not found.');
998
+ }
999
+
1000
+ ctx.send({ data: product });
1001
+ } catch (error) {
1002
+ strapi.log.error(`[${PLUGIN_ID}] Error in delete:`, error);
1003
+ ctx.internalServerError('Failed to delete product.');
1004
+ }
1005
+ },
1006
+ };