@o2vend/theme-cli 1.0.34 → 1.0.35
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/lib/lib/dev-server.js +474 -316
- package/lib/lib/webstoreapi-fetcher.js +322 -0
- package/package.json +16 -16
- package/test-theme/layout/theme.liquid +195 -195
package/lib/lib/dev-server.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Development Server
|
|
3
3
|
* Express server for local theme development with hot reload
|
|
4
|
+
*
|
|
5
|
+
* Developer guideline: Do NOT add theme-specific CSS class hardcodes (e.g. targeting
|
|
6
|
+
* .mobile-bottom-nav, .site-header, etc.) in this file. Such fixes would force theme
|
|
7
|
+
* developers to use those exact class names. Theme behavior and styling must remain
|
|
8
|
+
* entirely under the theme; the CLI only serves assets, proxies, and generic tooling.
|
|
4
9
|
*/
|
|
5
10
|
|
|
6
11
|
const express = require('express');
|
|
@@ -11,6 +16,7 @@ const WidgetService = require('./widget-service');
|
|
|
11
16
|
const O2VendApiClient = require('./api-client');
|
|
12
17
|
const { setupHotReload } = require('./hot-reload');
|
|
13
18
|
const chalk = require('chalk');
|
|
19
|
+
const webstoreApi = require('./webstoreapi-fetcher');
|
|
14
20
|
|
|
15
21
|
class DevServer {
|
|
16
22
|
constructor(options = {}) {
|
|
@@ -130,29 +136,33 @@ class DevServer {
|
|
|
130
136
|
// Only process if it contains actual Liquid variables, not just comments
|
|
131
137
|
if (cssContent.includes('{{') && cssContent.includes('settings.')) {
|
|
132
138
|
try {
|
|
133
|
-
// Load settings from theme config (already extracts 'current' section)
|
|
134
139
|
const settings = this.loadThemeSettings();
|
|
135
|
-
|
|
136
|
-
// Process through Liquid
|
|
137
140
|
const processedCss = await this.liquid.parseAndRender(cssContent, { settings });
|
|
138
|
-
|
|
139
141
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
140
142
|
res.setHeader('Cache-Control', 'no-cache');
|
|
141
143
|
return res.send(processedCss);
|
|
142
144
|
} catch (liquidError) {
|
|
143
145
|
console.error(`[CSS] Liquid processing error for ${relativePath}:`, liquidError.message);
|
|
144
|
-
// Fall through to serve static file
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
148
|
|
|
148
|
-
// Serve static file if no Liquid processing needed or if processing failed
|
|
149
149
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
150
|
-
res.sendFile(
|
|
150
|
+
res.sendFile(relativePath, { root: path.join(this.themePath, 'assets') });
|
|
151
151
|
} catch (error) {
|
|
152
152
|
console.error(`[CSS] Error processing ${req.path}:`, error.message);
|
|
153
153
|
next();
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
|
+
|
|
157
|
+
// Explicit logo route so /assets/logo.png always serves correctly (avoids broken image)
|
|
158
|
+
this.app.get('/assets/logo.png', (req, res) => {
|
|
159
|
+
const logoPath = path.join(this.themePath, 'assets', 'logo.png');
|
|
160
|
+
if (!fs.existsSync(logoPath)) {
|
|
161
|
+
return res.status(404).setHeader('Content-Type', 'text/plain').end('Logo not found');
|
|
162
|
+
}
|
|
163
|
+
res.setHeader('Content-Type', 'image/png');
|
|
164
|
+
res.sendFile('logo.png', { root: path.join(this.themePath, 'assets') });
|
|
165
|
+
});
|
|
156
166
|
|
|
157
167
|
// Static files (theme assets) with proper MIME types
|
|
158
168
|
this.app.use('/assets', express.static(path.join(this.themePath, 'assets'), {
|
|
@@ -225,37 +235,36 @@ class DevServer {
|
|
|
225
235
|
|
|
226
236
|
/**
|
|
227
237
|
* Setup API client and widget service
|
|
238
|
+
* Real mode: no apiClient (use webstoreapi-fetcher only). WidgetService with null for getTemplateSlug.
|
|
239
|
+
* Mock mode: apiClient created in start() after mock API; WidgetService uses it.
|
|
228
240
|
*/
|
|
229
241
|
async setupServices() {
|
|
230
242
|
if (this.mode === 'real') {
|
|
231
|
-
// Real API mode - create API client from environment
|
|
232
|
-
const tenantId = process.env.O2VEND_TENANT_ID;
|
|
233
|
-
const apiKey = process.env.O2VEND_API_KEY;
|
|
234
243
|
const baseUrl = process.env.O2VEND_API_BASE_URL;
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_TENANT_ID, O2VEND_API_KEY, and O2VEND_API_BASE_URL'));
|
|
244
|
+
if (!baseUrl) {
|
|
245
|
+
console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_API_BASE_URL (storefront URL, e.g. https://store.myo2vend.com)'));
|
|
238
246
|
console.warn(chalk.yellow(' Falling back to mock mode'));
|
|
239
247
|
this.mode = 'mock';
|
|
240
248
|
} else {
|
|
241
|
-
this.apiClient =
|
|
249
|
+
this.apiClient = null;
|
|
250
|
+
this.widgetService = new WidgetService(null, {
|
|
251
|
+
theme: path.basename(this.themePath),
|
|
252
|
+
themePath: this.themePath
|
|
253
|
+
});
|
|
242
254
|
}
|
|
243
255
|
}
|
|
244
|
-
|
|
245
|
-
// In mock mode, create API client - will be updated after mock API starts
|
|
246
|
-
// We create a placeholder here, actual client will be created in start() after mock API is running
|
|
256
|
+
|
|
247
257
|
if (this.mode === 'mock') {
|
|
248
|
-
// Create a temporary client - will be replaced in start() method
|
|
249
258
|
this.apiClient = null;
|
|
250
259
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
260
|
+
|
|
261
|
+
if (this.mode === 'mock' || !this.widgetService) {
|
|
262
|
+
if (this.apiClient) {
|
|
263
|
+
this.widgetService = new WidgetService(this.apiClient, {
|
|
264
|
+
theme: path.basename(this.themePath),
|
|
265
|
+
themePath: this.themePath
|
|
266
|
+
});
|
|
267
|
+
}
|
|
259
268
|
}
|
|
260
269
|
|
|
261
270
|
// Create Liquid engine
|
|
@@ -420,9 +429,15 @@ class DevServer {
|
|
|
420
429
|
if (!context.products || context.products.length === 0) {
|
|
421
430
|
console.warn('[HOME] No products in context, attempting reload...');
|
|
422
431
|
try {
|
|
423
|
-
if (this.
|
|
432
|
+
if (this.mode === 'real') {
|
|
433
|
+
const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
434
|
+
context.products = res?.products || res?.Products || [];
|
|
435
|
+
context.products = this.enrichProductsData(context.products);
|
|
436
|
+
console.log(`[HOME] Reloaded ${context.products.length} products (real)`);
|
|
437
|
+
} else if (this.apiClient) {
|
|
424
438
|
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
|
|
425
439
|
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
440
|
+
context.products = this.enrichProductsData(context.products);
|
|
426
441
|
console.log(`[HOME] Reloaded ${context.products.length} products`);
|
|
427
442
|
} else {
|
|
428
443
|
console.error('[HOME] API client not available');
|
|
@@ -431,12 +446,15 @@ class DevServer {
|
|
|
431
446
|
console.error('[HOME] Failed to reload products:', error.message);
|
|
432
447
|
}
|
|
433
448
|
}
|
|
434
|
-
|
|
435
|
-
// Ensure widgets are available
|
|
449
|
+
|
|
436
450
|
if (!context.widgets || Object.keys(context.widgets).length === 0) {
|
|
437
451
|
console.warn('[HOME] No widgets in context, attempting reload...');
|
|
438
452
|
try {
|
|
439
|
-
if (this.
|
|
453
|
+
if (this.mode === 'real') {
|
|
454
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
455
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections('home', webstoreApi.DEFAULT_SECTIONS, opts) || { hero: [], content: [], footer: [] };
|
|
456
|
+
console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')} (real)`);
|
|
457
|
+
} else if (this.widgetService) {
|
|
440
458
|
const widgets = await this.widgetService.getWidgetsBySections();
|
|
441
459
|
context.widgets = widgets || { hero: [], content: [], footer: [] };
|
|
442
460
|
console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
|
|
@@ -449,7 +467,7 @@ class DevServer {
|
|
|
449
467
|
context.widgets = { hero: [], content: [], footer: [] };
|
|
450
468
|
}
|
|
451
469
|
}
|
|
452
|
-
|
|
470
|
+
|
|
453
471
|
const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
|
|
454
472
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
455
473
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -471,22 +489,22 @@ class DevServer {
|
|
|
471
489
|
if (categoryFilter) {
|
|
472
490
|
console.log(`[PRODUCTS PAGE] Filtering by category: ${categoryFilter}`);
|
|
473
491
|
|
|
474
|
-
|
|
475
|
-
const category = context.categories?.find(c =>
|
|
476
|
-
(c.
|
|
477
|
-
(c.
|
|
478
|
-
|
|
492
|
+
const cf = (categoryFilter || '').toLowerCase();
|
|
493
|
+
const category = context.categories?.find(c => {
|
|
494
|
+
const ch = (c.handle || '').toLowerCase();
|
|
495
|
+
const cs = (c.slug || '').toLowerCase();
|
|
496
|
+
const cn = (c.name || '').toLowerCase();
|
|
497
|
+
const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
498
|
+
return ch === cf || cs === cf || cn === cf || cnNorm === cf;
|
|
499
|
+
});
|
|
479
500
|
|
|
480
501
|
if (category) {
|
|
481
502
|
console.log(`[PRODUCTS PAGE] Found category: ${category.name} (ID: ${category.id})`);
|
|
482
503
|
|
|
483
|
-
|
|
484
|
-
const filteredProducts =
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
|
|
488
|
-
)
|
|
489
|
-
.map(p => this.enrichProductData(p)); // Enrich each product
|
|
504
|
+
const filtered = context.products.filter(p => this.productMatchesCategory(p, category));
|
|
505
|
+
const filteredProducts = this.mode === 'real'
|
|
506
|
+
? filtered
|
|
507
|
+
: filtered.map(p => this.enrichProductData(p));
|
|
490
508
|
|
|
491
509
|
// CRITICAL: Set both context.products and context.collection.products to the SAME enriched array
|
|
492
510
|
// This ensures they always reference the same objects (like widgets do)
|
|
@@ -507,14 +525,11 @@ class DevServer {
|
|
|
507
525
|
}
|
|
508
526
|
}
|
|
509
527
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
} else if (context.products && context.products.length > 0) {
|
|
516
|
-
// Products already enriched
|
|
517
|
-
console.log(`[PRODUCTS PAGE] Products already enriched, first product has URL: ${context.products[0].url || context.products[0].link}`);
|
|
528
|
+
if (context.products && context.products.length > 0 && this.mode !== 'real') {
|
|
529
|
+
if (!context.products[0].url && !context.products[0].link) {
|
|
530
|
+
console.log(`[PRODUCTS PAGE] Products need enrichment - enriching now...`);
|
|
531
|
+
context.products = this.enrichProductsData(context.products);
|
|
532
|
+
}
|
|
518
533
|
}
|
|
519
534
|
|
|
520
535
|
// Debug: Log sample product data to verify enrichment
|
|
@@ -543,23 +558,26 @@ class DevServer {
|
|
|
543
558
|
if (!context.products || context.products.length === 0) {
|
|
544
559
|
console.warn('[PRODUCTS PAGE] ⚠️ No products in context, attempting to reload...');
|
|
545
560
|
try {
|
|
546
|
-
const queryParams = {};
|
|
561
|
+
const queryParams = { limit: 50, offset: 0 };
|
|
547
562
|
if (categoryFilter) {
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
(c.
|
|
551
|
-
(c.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
563
|
+
const cf = (categoryFilter || '').toLowerCase();
|
|
564
|
+
const cat = context.categories?.find(c => {
|
|
565
|
+
const ch = (c.handle || '').toLowerCase();
|
|
566
|
+
const cs = (c.slug || '').toLowerCase();
|
|
567
|
+
const cn = (c.name || '').toLowerCase();
|
|
568
|
+
const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
569
|
+
return ch === cf || cs === cf || cn === cf || cnNorm === cf;
|
|
570
|
+
});
|
|
571
|
+
if (cat) queryParams.categoryId = cat.id;
|
|
572
|
+
}
|
|
573
|
+
if (this.mode === 'real') {
|
|
574
|
+
const res = await webstoreApi.fetchProducts(queryParams, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
575
|
+
context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
|
|
576
|
+
} else if (this.apiClient) {
|
|
577
|
+
const productsResponse = await this.apiClient.getProducts(queryParams);
|
|
578
|
+
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
579
|
+
context.products = this.enrichProductsData(context.products);
|
|
556
580
|
}
|
|
557
|
-
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0, ...queryParams });
|
|
558
|
-
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
559
|
-
|
|
560
|
-
// Ensure URLs and all product data are set
|
|
561
|
-
context.products = this.enrichProductsData(context.products);
|
|
562
|
-
|
|
563
581
|
console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.products.length} products`);
|
|
564
582
|
if (context.products.length > 0) {
|
|
565
583
|
const sample = context.products[0];
|
|
@@ -598,12 +616,15 @@ class DevServer {
|
|
|
598
616
|
if (!context.widgets || Object.keys(context.widgets).length === 0) {
|
|
599
617
|
console.warn('[PRODUCTS PAGE] ⚠️ No widgets in context, attempting to reload...');
|
|
600
618
|
try {
|
|
601
|
-
if (this.
|
|
619
|
+
if (this.mode === 'real') {
|
|
620
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
621
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections('products', webstoreApi.DEFAULT_SECTIONS, opts) || { hero: [], content: [], footer: [], header: [] };
|
|
622
|
+
console.log(`[PRODUCTS PAGE] ✅ Reloaded widgets: ${Object.keys(context.widgets).join(', ')} (real)`);
|
|
623
|
+
} else if (this.widgetService) {
|
|
602
624
|
const widgets = await this.widgetService.getWidgetsBySections();
|
|
603
625
|
context.widgets = widgets || { hero: [], content: [], footer: [], header: [] };
|
|
604
626
|
console.log(`[PRODUCTS PAGE] ✅ Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
|
|
605
627
|
} else {
|
|
606
|
-
console.error('[PRODUCTS PAGE] ❌ Widget service not available');
|
|
607
628
|
context.widgets = { hero: [], content: [], footer: [], header: [] };
|
|
608
629
|
}
|
|
609
630
|
} catch (error) {
|
|
@@ -611,16 +632,22 @@ class DevServer {
|
|
|
611
632
|
context.widgets = { hero: [], content: [], footer: [], header: [] };
|
|
612
633
|
}
|
|
613
634
|
}
|
|
614
|
-
|
|
635
|
+
|
|
615
636
|
// Ensure menus are available
|
|
616
637
|
if (!context.menus || context.menus.length === 0) {
|
|
617
638
|
console.warn('[PRODUCTS PAGE] ⚠️ No menus in context, attempting to reload...');
|
|
618
639
|
try {
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
640
|
+
if (this.mode === 'real') {
|
|
641
|
+
context.menus = await webstoreApi.fetchMenus({ headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
642
|
+
context.menus = Array.isArray(context.menus) ? context.menus : [];
|
|
643
|
+
console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus (real)`);
|
|
644
|
+
} else {
|
|
645
|
+
const axios = require('axios');
|
|
646
|
+
const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
|
|
647
|
+
const menusData = menusResponse.data;
|
|
648
|
+
context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
|
|
649
|
+
console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus`);
|
|
650
|
+
}
|
|
624
651
|
} catch (error) {
|
|
625
652
|
console.error('[PRODUCTS PAGE] ❌ Failed to reload menus:', error.message);
|
|
626
653
|
context.menus = [];
|
|
@@ -873,31 +900,24 @@ class DevServer {
|
|
|
873
900
|
this.app.get('/collections/:handle', async (req, res, next) => {
|
|
874
901
|
try {
|
|
875
902
|
const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
|
|
876
|
-
|
|
877
|
-
const category = context.categories?.find(c =>
|
|
878
|
-
|
|
879
|
-
c.
|
|
880
|
-
|
|
903
|
+
const h = (req.params.handle || '').toLowerCase();
|
|
904
|
+
const category = context.categories?.find(c => {
|
|
905
|
+
const ch = (c.handle || '').toLowerCase();
|
|
906
|
+
const cs = (c.slug || '').toLowerCase();
|
|
907
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
908
|
+
return ch === h || cs === h || cn === h;
|
|
909
|
+
});
|
|
881
910
|
if (category) {
|
|
882
911
|
context.category = category;
|
|
883
912
|
context.collection = category;
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const filteredProducts = (context.products || [])
|
|
887
|
-
.filter(p =>
|
|
888
|
-
p.categoryId === category.id ||
|
|
889
|
-
String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
|
|
890
|
-
)
|
|
891
|
-
.map(p => this.enrichProductData(p));
|
|
892
|
-
|
|
913
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
914
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
893
915
|
context.collection.products = filteredProducts;
|
|
894
916
|
context.collection.totalProducts = filteredProducts.length;
|
|
895
|
-
context.products = filteredProducts;
|
|
896
|
-
|
|
917
|
+
context.products = filteredProducts;
|
|
897
918
|
console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
|
|
898
919
|
} else {
|
|
899
|
-
|
|
900
|
-
context.products = this.enrichProductsData(context.products || []);
|
|
920
|
+
if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
|
|
901
921
|
context.collection = context.collection || {};
|
|
902
922
|
context.collection.products = context.products;
|
|
903
923
|
}
|
|
@@ -956,30 +976,24 @@ class DevServer {
|
|
|
956
976
|
this.app.get('/categories/:handle', async (req, res, next) => {
|
|
957
977
|
try {
|
|
958
978
|
const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
c.
|
|
962
|
-
|
|
979
|
+
const h = (req.params.handle || '').toLowerCase();
|
|
980
|
+
const category = context.categories?.find(c => {
|
|
981
|
+
const ch = (c.handle || '').toLowerCase();
|
|
982
|
+
const cs = (c.slug || '').toLowerCase();
|
|
983
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
984
|
+
return ch === h || cs === h || cn === h;
|
|
985
|
+
});
|
|
963
986
|
if (category) {
|
|
964
987
|
context.category = category;
|
|
965
988
|
context.collection = category;
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const filteredProducts = (context.products || [])
|
|
969
|
-
.filter(p =>
|
|
970
|
-
p.categoryId === category.id ||
|
|
971
|
-
String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
|
|
972
|
-
)
|
|
973
|
-
.map(p => this.enrichProductData(p));
|
|
974
|
-
|
|
989
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
990
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
975
991
|
context.collection.products = filteredProducts;
|
|
976
992
|
context.collection.totalProducts = filteredProducts.length;
|
|
977
|
-
context.products = filteredProducts;
|
|
978
|
-
|
|
993
|
+
context.products = filteredProducts;
|
|
979
994
|
console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
|
|
980
995
|
} else {
|
|
981
|
-
|
|
982
|
-
context.products = this.enrichProductsData(context.products || []);
|
|
996
|
+
if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
|
|
983
997
|
context.collection = context.collection || {};
|
|
984
998
|
context.collection.products = context.products;
|
|
985
999
|
}
|
|
@@ -1107,41 +1121,38 @@ class DevServer {
|
|
|
1107
1121
|
try {
|
|
1108
1122
|
const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
|
|
1109
1123
|
|
|
1110
|
-
// Load page content
|
|
1124
|
+
// Load page content
|
|
1111
1125
|
try {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
context.page = {
|
|
1116
|
-
...context.page,
|
|
1117
|
-
...pageResponse.data,
|
|
1118
|
-
body_html: pageResponse.data.content || pageResponse.data.htmlContent,
|
|
1119
|
-
content: pageResponse.data.content || pageResponse.data.htmlContent
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
} catch (error) {
|
|
1123
|
-
console.warn(`[PAGE] Failed to load landing page for ${req.params.handle}:`, error.message);
|
|
1124
|
-
// Fallback to mock API pages endpoint
|
|
1125
|
-
try {
|
|
1126
|
-
const axios = require('axios');
|
|
1127
|
-
const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
|
|
1128
|
-
if (pageResponse.data) {
|
|
1126
|
+
if (this.mode === 'real') {
|
|
1127
|
+
const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1128
|
+
if (pageData) {
|
|
1129
1129
|
context.page = {
|
|
1130
1130
|
...context.page,
|
|
1131
|
-
...
|
|
1132
|
-
body_html:
|
|
1133
|
-
content:
|
|
1131
|
+
...pageData,
|
|
1132
|
+
body_html: pageData.content || pageData.body_html,
|
|
1133
|
+
content: pageData.content || pageData.body_html
|
|
1134
1134
|
};
|
|
1135
1135
|
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
}
|
|
1136
|
+
} else {
|
|
1137
|
+
const axios = require('axios');
|
|
1138
|
+
try {
|
|
1139
|
+
const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
|
|
1140
|
+
if (pageResponse.data) {
|
|
1141
|
+
context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content || pageResponse.data.htmlContent, content: pageResponse.data.content || pageResponse.data.htmlContent };
|
|
1142
|
+
}
|
|
1143
|
+
} catch (e) {
|
|
1144
|
+
const fallback = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
|
|
1145
|
+
if (fallback.data) {
|
|
1146
|
+
context.page = { ...context.page, ...fallback.data, body_html: fallback.data.content, content: fallback.data.content };
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1144
1149
|
}
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
console.warn(`[PAGE] Failed to load page for ${req.params.handle}:`, error.message);
|
|
1152
|
+
}
|
|
1153
|
+
if (!context.page.body_html && !context.page.content) {
|
|
1154
|
+
const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
|
|
1155
|
+
context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Landing page content coming soon.</p>`, content: `<h2>${t}</h2><p>Landing page content coming soon.</p>` };
|
|
1145
1156
|
}
|
|
1146
1157
|
|
|
1147
1158
|
// Try page.liquid template first, then fallback to rendering raw HTML
|
|
@@ -1168,30 +1179,27 @@ class DevServer {
|
|
|
1168
1179
|
try {
|
|
1169
1180
|
const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
|
|
1170
1181
|
|
|
1171
|
-
// Load page content from mock API
|
|
1172
1182
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1183
|
+
if (this.mode === 'real') {
|
|
1184
|
+
const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1185
|
+
if (pageData) {
|
|
1186
|
+
context.page = { ...context.page, ...pageData, body_html: pageData.content || pageData.body_html, content: pageData.content || pageData.body_html };
|
|
1187
|
+
}
|
|
1188
|
+
} else {
|
|
1189
|
+
const axios = require('axios');
|
|
1190
|
+
const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
|
|
1191
|
+
if (pageResponse.data) {
|
|
1192
|
+
context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content, content: pageResponse.data.content };
|
|
1193
|
+
}
|
|
1182
1194
|
}
|
|
1183
1195
|
} catch (error) {
|
|
1184
1196
|
console.warn(`[PAGE] Failed to load page content for ${req.params.handle}:`, error.message);
|
|
1185
|
-
// Set default page content
|
|
1186
|
-
context.page = {
|
|
1187
|
-
...context.page,
|
|
1188
|
-
title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
|
|
1189
|
-
handle: req.params.handle,
|
|
1190
|
-
body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`,
|
|
1191
|
-
content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`
|
|
1192
|
-
};
|
|
1193
1197
|
}
|
|
1194
|
-
|
|
1198
|
+
if (!context.page.body_html && !context.page.content) {
|
|
1199
|
+
const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
|
|
1200
|
+
context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Page content coming soon.</p>`, content: `<h2>${t}</h2><p>Page content coming soon.</p>` };
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1195
1203
|
const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
|
|
1196
1204
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1197
1205
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -1249,10 +1257,13 @@ class DevServer {
|
|
|
1249
1257
|
const widgetType = req.params.type.replace(/\.liquid$/, '');
|
|
1250
1258
|
const context = await this.buildContext(req, 'home');
|
|
1251
1259
|
|
|
1252
|
-
// Ensure products are loaded for widget context (needed for product widgets)
|
|
1253
1260
|
if (!context.products || context.products.length === 0) {
|
|
1254
1261
|
try {
|
|
1255
|
-
if (this.
|
|
1262
|
+
if (this.mode === 'real') {
|
|
1263
|
+
const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1264
|
+
context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
|
|
1265
|
+
console.log(`[DEV WIDGET] Loaded ${context.products.length} products (real)`);
|
|
1266
|
+
} else if (this.apiClient) {
|
|
1256
1267
|
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
|
|
1257
1268
|
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
1258
1269
|
console.log(`[DEV WIDGET] Loaded ${context.products.length} products for widget context`);
|
|
@@ -1408,32 +1419,22 @@ class DevServer {
|
|
|
1408
1419
|
// Build context to get categories, brands, and products
|
|
1409
1420
|
const context = await this.buildContext(req, 'collection', { collectionHandle: handle });
|
|
1410
1421
|
|
|
1411
|
-
// Try to find category by handle (case-insensitive)
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
(c.
|
|
1415
|
-
|
|
1422
|
+
// Try to find category by handle or slug (case-insensitive)
|
|
1423
|
+
const h = handle.toLowerCase();
|
|
1424
|
+
const category = context.categories?.find(c => {
|
|
1425
|
+
const ch = (c.handle || '').toLowerCase();
|
|
1426
|
+
const cs = (c.slug || '').toLowerCase();
|
|
1427
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
1428
|
+
return ch === h || cs === h || cn === h;
|
|
1429
|
+
});
|
|
1416
1430
|
|
|
1417
1431
|
if (category) {
|
|
1418
|
-
// Found a category - render products list (NOT categories list)
|
|
1419
1432
|
context.category = category;
|
|
1420
1433
|
context.collection = category;
|
|
1421
|
-
|
|
1422
1434
|
console.log(`[ROOT COLLECTION] Found category: ${category.name} (ID: ${category.id})`);
|
|
1423
1435
|
console.log(`[ROOT COLLECTION] Total products in context: ${context.products?.length || 0}`);
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
const filteredProducts = (context.products || [])
|
|
1427
|
-
.filter(p => {
|
|
1428
|
-
const matches = p.categoryId === category.id ||
|
|
1429
|
-
String(p.categoryId).toLowerCase() === String(category.id).toLowerCase();
|
|
1430
|
-
if (matches) {
|
|
1431
|
-
console.log(`[ROOT COLLECTION] Product matches: ${p.title || p.name || p.id}, categoryId: ${p.categoryId}`);
|
|
1432
|
-
}
|
|
1433
|
-
return matches;
|
|
1434
|
-
})
|
|
1435
|
-
.map(p => this.enrichProductData(p)); // Enrich each product
|
|
1436
|
-
|
|
1436
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
1437
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
1437
1438
|
console.log(`[ROOT COLLECTION] Filtered products count: ${filteredProducts.length}`);
|
|
1438
1439
|
|
|
1439
1440
|
// Log sample enriched products
|
|
@@ -1536,17 +1537,13 @@ class DevServer {
|
|
|
1536
1537
|
);
|
|
1537
1538
|
|
|
1538
1539
|
if (brand) {
|
|
1539
|
-
// Found a brand - render products list
|
|
1540
1540
|
context.brand = brand;
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
(p.brand && p.brand.toLowerCase() === brand.name.toLowerCase())
|
|
1548
|
-
)
|
|
1549
|
-
.map(p => this.enrichProductData(p));
|
|
1541
|
+
const filtered = (context.products || []).filter(p =>
|
|
1542
|
+
p.brandId === brand.id ||
|
|
1543
|
+
String(p.brandId).toLowerCase() === String(brand.id).toLowerCase() ||
|
|
1544
|
+
(p.brand && p.brand.toLowerCase() === brand.name.toLowerCase())
|
|
1545
|
+
);
|
|
1546
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
1550
1547
|
|
|
1551
1548
|
context.brand.products = filteredProducts;
|
|
1552
1549
|
context.products = filteredProducts;
|
|
@@ -1612,20 +1609,16 @@ class DevServer {
|
|
|
1612
1609
|
}
|
|
1613
1610
|
});
|
|
1614
1611
|
|
|
1615
|
-
// Favicon handler
|
|
1612
|
+
// Favicon handler: serve from theme assets if present, else 204
|
|
1616
1613
|
this.app.get('/favicon.ico', (req, res) => {
|
|
1617
|
-
|
|
1614
|
+
const faviconPath = path.join(this.themePath, 'assets', 'favicon.ico');
|
|
1615
|
+
if (fs.existsSync(faviconPath)) {
|
|
1616
|
+
res.setHeader('Content-Type', 'image/x-icon');
|
|
1617
|
+
return res.sendFile('favicon.ico', { root: path.join(this.themePath, 'assets') });
|
|
1618
|
+
}
|
|
1619
|
+
res.status(204).end();
|
|
1618
1620
|
});
|
|
1619
1621
|
|
|
1620
|
-
// API proxy (for real API mode)
|
|
1621
|
-
if (this.mode === 'real' && this.apiClient) {
|
|
1622
|
-
this.app.use('/api', (req, res, next) => {
|
|
1623
|
-
// Proxy API requests to real API
|
|
1624
|
-
// This is handled by mock API in mock mode
|
|
1625
|
-
next();
|
|
1626
|
-
});
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
1622
|
// Error handler
|
|
1630
1623
|
this.app.use((error, req, res, next) => {
|
|
1631
1624
|
// Ignore request aborted errors - they're harmless (client cancelled request)
|
|
@@ -1947,6 +1940,14 @@ class DevServer {
|
|
|
1947
1940
|
console.error('[CONTEXT] Error details:', error.response?.status, error.response?.data);
|
|
1948
1941
|
context.menus = [];
|
|
1949
1942
|
}
|
|
1943
|
+
|
|
1944
|
+
// Set mainMenu for mega-menu fallback (header when no HeaderMenu widget)
|
|
1945
|
+
let mainMenu = null;
|
|
1946
|
+
if (context.menus && context.menus.length > 0) {
|
|
1947
|
+
const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
|
|
1948
|
+
mainMenu = main || context.menus[0];
|
|
1949
|
+
}
|
|
1950
|
+
context.mainMenu = mainMenu;
|
|
1950
1951
|
|
|
1951
1952
|
// Load cart data
|
|
1952
1953
|
try {
|
|
@@ -2043,59 +2044,164 @@ class DevServer {
|
|
|
2043
2044
|
content: []
|
|
2044
2045
|
};
|
|
2045
2046
|
}
|
|
2046
|
-
} else if (this.
|
|
2047
|
-
//
|
|
2047
|
+
} else if (this.mode === 'real') {
|
|
2048
|
+
// Real mode: use webstoreapi-fetcher only (no apiClient). Thin HTTP calls to /webstoreapi/*.
|
|
2049
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
2050
|
+
const baseHost = (process.env.O2VEND_API_BASE_URL || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '') || 'localhost';
|
|
2051
|
+
const themeSettings = context.settings || {};
|
|
2052
|
+
let storeInfo = null;
|
|
2048
2053
|
try {
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2054
|
+
storeInfo = await webstoreApi.fetchStoreInfo();
|
|
2055
|
+
} catch (e) {
|
|
2056
|
+
/* use fallbacks */
|
|
2057
|
+
}
|
|
2058
|
+
const logo = (storeInfo && storeInfo.logoUrl) ? storeInfo.logoUrl : '/assets/logo.png';
|
|
2059
|
+
const favicon = (storeInfo && storeInfo.favouriteIconUrl) ? storeInfo.favouriteIconUrl : '/favicon.ico';
|
|
2060
|
+
const storeName = (storeInfo && storeInfo.name) ? storeInfo.name : 'Store';
|
|
2061
|
+
const storeSettings = (storeInfo && storeInfo.settings) || {};
|
|
2062
|
+
try {
|
|
2063
|
+
context.shop = {
|
|
2064
|
+
name: storeName,
|
|
2065
|
+
domain: baseHost,
|
|
2066
|
+
description: 'O2VEND Store',
|
|
2067
|
+
email: storeSettings.companyEmail || 'store@example.com',
|
|
2068
|
+
logo,
|
|
2069
|
+
favicon,
|
|
2070
|
+
currency: storeSettings.currency || themeSettings.currency || 'USD',
|
|
2071
|
+
language: storeSettings.language || themeSettings.language || 'en',
|
|
2072
|
+
locale: themeSettings.locale || 'en-US',
|
|
2073
|
+
settings: {
|
|
2074
|
+
currency: storeSettings.currency || themeSettings.currency || 'USD',
|
|
2075
|
+
currencySymbol: storeSettings.currencySymbol || storeSettings.currency_symbol || themeSettings.currency_symbol || themeSettings.currencySymbol || '$',
|
|
2076
|
+
logo,
|
|
2077
|
+
favicon
|
|
2078
|
+
}
|
|
2079
|
+
};
|
|
2080
|
+
context.tenant = { id: process.env.O2VEND_TENANT_ID || 'real' };
|
|
2081
|
+
|
|
2082
|
+
if (process.env.O2VEND_LAYOUT_FULL === '1' || process.env.O2VEND_LAYOUT_FULL === 'true') {
|
|
2083
|
+
context.settings = context.settings || {};
|
|
2084
|
+
context.settings.layout_style = 'full-width';
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
const page = pageType === 'home' ? 'home' : (pageType === 'products' ? 'products' : 'home');
|
|
2088
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
|
|
2089
|
+
context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
|
|
2090
|
+
|
|
2091
|
+
if (this.widgetService) {
|
|
2092
|
+
Object.keys(context.widgets).forEach((section) => {
|
|
2093
|
+
if (Array.isArray(context.widgets[section])) {
|
|
2094
|
+
context.widgets[section].forEach((widget) => {
|
|
2095
|
+
if (!widget.template_path && widget.template) widget.template_path = `widgets/${widget.template}`;
|
|
2096
|
+
if (!widget.template_path && widget.type) {
|
|
2097
|
+
const slug = this.widgetService.getTemplateSlug(widget.type);
|
|
2098
|
+
widget.template_path = `widgets/${slug}`;
|
|
2099
|
+
widget.template = slug;
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
const productsRes = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, opts);
|
|
2107
|
+
context.products = productsRes?.products || productsRes?.Products || [];
|
|
2108
|
+
context.products = this.normalizeProductsForRealMode(context.products);
|
|
2109
|
+
|
|
2110
|
+
try {
|
|
2111
|
+
const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
|
|
2112
|
+
const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
|
|
2113
|
+
context.categories = raw.map((c) => {
|
|
2114
|
+
const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().replace(/^\s+|\s+$/g, '') || null;
|
|
2115
|
+
if (h) {
|
|
2116
|
+
c.handle = c.handle || h;
|
|
2117
|
+
c.slug = c.slug || h;
|
|
2118
|
+
c.url = `/${h}`;
|
|
2119
|
+
c.link = `/${h}`;
|
|
2120
|
+
}
|
|
2121
|
+
return c;
|
|
2122
|
+
});
|
|
2123
|
+
context.collections = context.categories;
|
|
2124
|
+
} catch (e) {
|
|
2125
|
+
context.categories = [];
|
|
2126
|
+
context.collections = [];
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
try {
|
|
2130
|
+
const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
|
|
2131
|
+
context.brands = brandRes?.brands || brandRes?.data?.brands || brandRes || [];
|
|
2132
|
+
} catch (e) {
|
|
2133
|
+
context.brands = [];
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
try {
|
|
2137
|
+
context.menus = await webstoreApi.fetchMenus(opts);
|
|
2138
|
+
context.menus = Array.isArray(context.menus) ? context.menus : [];
|
|
2139
|
+
let mainMenu = null;
|
|
2140
|
+
if (context.menus.length > 0) {
|
|
2141
|
+
const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
|
|
2142
|
+
const candidate = main || context.menus[0];
|
|
2143
|
+
if (candidate && candidate.id) {
|
|
2144
|
+
try {
|
|
2145
|
+
mainMenu = await webstoreApi.fetchMenuById(candidate.id, opts);
|
|
2146
|
+
} catch (e) {
|
|
2147
|
+
/* ignore */
|
|
2068
2148
|
}
|
|
2069
|
-
}
|
|
2149
|
+
}
|
|
2070
2150
|
}
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
context.
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2151
|
+
context.mainMenu = mainMenu;
|
|
2152
|
+
} catch (e) {
|
|
2153
|
+
context.menus = [];
|
|
2154
|
+
context.mainMenu = null;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
try {
|
|
2158
|
+
const cartRes = await webstoreApi.fetchCart(opts);
|
|
2159
|
+
const data = cartRes?.data || cartRes;
|
|
2160
|
+
if (data) {
|
|
2161
|
+
context.cart = {
|
|
2162
|
+
...context.cart,
|
|
2163
|
+
...data,
|
|
2164
|
+
itemCount: data.items?.length ?? data.itemCount ?? 0,
|
|
2165
|
+
total: data.total ?? data.subTotal ?? 0
|
|
2166
|
+
};
|
|
2085
2167
|
}
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2168
|
+
} catch (e) {
|
|
2169
|
+
/* cart optional */
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
if (pageType === 'products') {
|
|
2173
|
+
context.collection = context.collection || {};
|
|
2174
|
+
context.collection.products = context.products;
|
|
2175
|
+
context.collection.title = 'All Products';
|
|
2176
|
+
context.collection.handle = 'all';
|
|
2177
|
+
context.collection.totalProducts = context.products.length;
|
|
2089
2178
|
} else if (pageType === 'collection') {
|
|
2090
|
-
// Load products for collection
|
|
2091
|
-
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
|
|
2092
|
-
context.products = productsResponse.products || [];
|
|
2093
|
-
// Set collection.products for collection pages
|
|
2094
2179
|
context.collection = context.collection || {};
|
|
2095
2180
|
context.collection.products = context.products;
|
|
2181
|
+
} else if (pageType === 'product' && extra.productHandle) {
|
|
2182
|
+
const handle = String(extra.productHandle).toLowerCase().trim();
|
|
2183
|
+
let product = context.products.find((p) => {
|
|
2184
|
+
const h = (p.handle || '').toLowerCase().trim();
|
|
2185
|
+
const s = (p.slug || '').toLowerCase().trim();
|
|
2186
|
+
const id = String(p.id || '').toLowerCase().trim();
|
|
2187
|
+
const pid = String(p.productId || '').toLowerCase().trim();
|
|
2188
|
+
if (h === handle || s === handle || id === handle) return true;
|
|
2189
|
+
if (handle.startsWith('product-') && (pid === handle.replace(/^product-/, '') || id === handle.replace(/^product-/, ''))) return true;
|
|
2190
|
+
return false;
|
|
2191
|
+
});
|
|
2192
|
+
if (!product) {
|
|
2193
|
+
try {
|
|
2194
|
+
const idToFetch = handle.startsWith('product-') ? handle.replace(/^product-/, '') : handle;
|
|
2195
|
+
product = await webstoreApi.fetchProductById(idToFetch, opts);
|
|
2196
|
+
if (product) product = this.normalizeProductForRealMode(product);
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
/* ignore */
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
if (product) context.product = product;
|
|
2096
2202
|
}
|
|
2097
2203
|
} catch (error) {
|
|
2098
|
-
console.warn('[CONTEXT]
|
|
2204
|
+
console.warn('[CONTEXT] Real mode fetch failed:', error.message);
|
|
2099
2205
|
}
|
|
2100
2206
|
}
|
|
2101
2207
|
|
|
@@ -2133,36 +2239,18 @@ class DevServer {
|
|
|
2133
2239
|
// Widget templates use: widget.data.products, widget_data.products
|
|
2134
2240
|
if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
|
|
2135
2241
|
const widgetProducts = this.getProductsForWidget(widget, products);
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
widget.data.
|
|
2142
|
-
widget.
|
|
2143
|
-
widget.products = enrichedProducts;
|
|
2144
|
-
|
|
2145
|
-
// Also set in content for some template variations
|
|
2242
|
+
const formatted = this.mode === 'real'
|
|
2243
|
+
? this.normalizeProductsForRealMode(widgetProducts)
|
|
2244
|
+
: this.enrichProductsData(widgetProducts);
|
|
2245
|
+
|
|
2246
|
+
widget.data.products = formatted;
|
|
2247
|
+
widget.data.Products = formatted;
|
|
2248
|
+
widget.products = formatted;
|
|
2146
2249
|
widget.data.content = widget.data.content || {};
|
|
2147
|
-
widget.data.content.products =
|
|
2148
|
-
widget.data.content.Products =
|
|
2149
|
-
|
|
2250
|
+
widget.data.content.products = formatted;
|
|
2251
|
+
widget.data.content.Products = formatted;
|
|
2150
2252
|
enrichedCount++;
|
|
2151
|
-
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${
|
|
2152
|
-
|
|
2153
|
-
// Debug: Log sample product data
|
|
2154
|
-
if (enrichedProducts.length > 0) {
|
|
2155
|
-
const sample = enrichedProducts[0];
|
|
2156
|
-
console.log(`[ENRICH] Sample widget product:`, {
|
|
2157
|
-
id: sample.id,
|
|
2158
|
-
title: sample.title || sample.name,
|
|
2159
|
-
url: sample.url || sample.link,
|
|
2160
|
-
price: sample.price || sample.sellingPrice,
|
|
2161
|
-
stock: sample.stock,
|
|
2162
|
-
inStock: sample.inStock,
|
|
2163
|
-
hasImage: !!(sample.imageUrl || sample.thumbnailImage1)
|
|
2164
|
-
});
|
|
2165
|
-
}
|
|
2253
|
+
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${formatted.length} products`);
|
|
2166
2254
|
}
|
|
2167
2255
|
|
|
2168
2256
|
// Enrich CategoryList and CategoryListCarousel widgets with categories
|
|
@@ -2199,18 +2287,18 @@ class DevServer {
|
|
|
2199
2287
|
|
|
2200
2288
|
// Enrich RecentlyViewed widgets with products (use first 6 products as mock recent views)
|
|
2201
2289
|
if (type === 'recentlyviewed' || type === 'recently-viewed') {
|
|
2202
|
-
const
|
|
2290
|
+
const recent = products.slice(0, 6);
|
|
2291
|
+
const recentProducts = this.mode === 'real'
|
|
2292
|
+
? this.normalizeProductsForRealMode(recent)
|
|
2293
|
+
: this.enrichProductsData(recent);
|
|
2203
2294
|
widget.data.products = recentProducts;
|
|
2204
2295
|
widget.data.Products = recentProducts;
|
|
2205
2296
|
widget.products = recentProducts;
|
|
2206
|
-
|
|
2207
|
-
// Also set in content
|
|
2208
2297
|
widget.data.content = widget.data.content || {};
|
|
2209
2298
|
widget.data.content.products = recentProducts;
|
|
2210
2299
|
widget.data.content.Products = recentProducts;
|
|
2211
|
-
|
|
2212
2300
|
enrichedCount++;
|
|
2213
|
-
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length}
|
|
2301
|
+
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
|
|
2214
2302
|
}
|
|
2215
2303
|
});
|
|
2216
2304
|
});
|
|
@@ -2218,6 +2306,28 @@ class DevServer {
|
|
|
2218
2306
|
console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
|
|
2219
2307
|
}
|
|
2220
2308
|
|
|
2309
|
+
/**
|
|
2310
|
+
* Check if a product belongs to a category (for collection/category filtering).
|
|
2311
|
+
* Handles categoryId, primaryCategoryId, categoryHandle, and categories[].
|
|
2312
|
+
* @param {Object} p - Product
|
|
2313
|
+
* @param {Object} category - Category { id, handle }
|
|
2314
|
+
* @returns {boolean}
|
|
2315
|
+
*/
|
|
2316
|
+
productMatchesCategory(p, category) {
|
|
2317
|
+
const cid = category.id;
|
|
2318
|
+
const ch = (category.handle || category.slug || '').toLowerCase();
|
|
2319
|
+
if (p.categoryId != null && (p.categoryId === cid || String(p.categoryId).toLowerCase() === String(cid).toLowerCase())) return true;
|
|
2320
|
+
if (p.primaryCategoryId != null && (p.primaryCategoryId === cid || String(p.primaryCategoryId).toLowerCase() === String(cid).toLowerCase())) return true;
|
|
2321
|
+
const ph = (p.categoryHandle || p.category?.handle || '').toLowerCase();
|
|
2322
|
+
if (ch && ph && ph === ch) return true;
|
|
2323
|
+
const arr = p.categories || p.categoryIds || [];
|
|
2324
|
+
if (Array.isArray(arr) && arr.some(cat => {
|
|
2325
|
+
const o = typeof cat === 'object' ? cat : { id: cat };
|
|
2326
|
+
return o.id === cid || (o.handle || o.slug || '').toLowerCase() === ch;
|
|
2327
|
+
})) return true;
|
|
2328
|
+
return false;
|
|
2329
|
+
}
|
|
2330
|
+
|
|
2221
2331
|
/**
|
|
2222
2332
|
* Enrich a single product with all required data (URL, name/title, price, images, stock)
|
|
2223
2333
|
* This ensures consistency between widgets and pages
|
|
@@ -2254,42 +2364,24 @@ class DevServer {
|
|
|
2254
2364
|
}
|
|
2255
2365
|
|
|
2256
2366
|
// Ensure URL field exists - CRITICAL for product navigation
|
|
2257
|
-
//
|
|
2258
|
-
const
|
|
2259
|
-
const
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
enriched.handle = enriched.handle || fallbackHandle; // Also set handle if missing
|
|
2274
|
-
enriched.slug = enriched.slug || fallbackHandle; // Also set slug if missing
|
|
2275
|
-
} else {
|
|
2276
|
-
enriched.url = '#';
|
|
2277
|
-
enriched.link = '#';
|
|
2278
|
-
console.warn(`[ENRICH] ⚠️ Product has no id/handle/slug/productId, URL set to '#'`, {
|
|
2279
|
-
productKeys: Object.keys(enriched).slice(0, 10)
|
|
2280
|
-
});
|
|
2281
|
-
}
|
|
2282
|
-
}
|
|
2283
|
-
} else if (!hasValidUrl && hasValidLink) {
|
|
2284
|
-
enriched.url = enriched.link;
|
|
2285
|
-
} else if (hasValidUrl && !hasValidLink) {
|
|
2286
|
-
enriched.link = enriched.url;
|
|
2287
|
-
} else if (enriched.url === '#' || enriched.link === '#') {
|
|
2288
|
-
// If one is valid but the other is '#', update the '#' one
|
|
2289
|
-
if (enriched.url === '#') enriched.url = enriched.link;
|
|
2290
|
-
if (enriched.link === '#') enriched.link = enriched.url;
|
|
2367
|
+
// Always use /products/{handle} so links target dev server (never external URLs)
|
|
2368
|
+
const handle = enriched.handle || enriched.slug || enriched.id;
|
|
2369
|
+
const effectiveHandle = (handle && String(handle).trim() && String(handle) !== '#')
|
|
2370
|
+
? String(handle).trim().toLowerCase()
|
|
2371
|
+
: (enriched.productId ? `product-${enriched.productId}`.toLowerCase() : null);
|
|
2372
|
+
if (effectiveHandle) {
|
|
2373
|
+
enriched.url = `/products/${effectiveHandle}`;
|
|
2374
|
+
enriched.link = `/products/${effectiveHandle}`;
|
|
2375
|
+
if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') enriched.handle = effectiveHandle;
|
|
2376
|
+
if (!enriched.slug || enriched.slug === '#' || enriched.slug === '') enriched.slug = effectiveHandle;
|
|
2377
|
+
} else {
|
|
2378
|
+
enriched.url = '#';
|
|
2379
|
+
enriched.link = '#';
|
|
2380
|
+
console.warn(`[ENRICH] ⚠️ Product has no id/handle/slug/productId, URL set to '#'`, {
|
|
2381
|
+
productKeys: Object.keys(enriched).slice(0, 10)
|
|
2382
|
+
});
|
|
2291
2383
|
}
|
|
2292
|
-
|
|
2384
|
+
|
|
2293
2385
|
// Ensure handle/slug exist if missing (for template fallback logic)
|
|
2294
2386
|
// Also normalize them (remove '#' or empty strings)
|
|
2295
2387
|
if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') {
|
|
@@ -2437,6 +2529,59 @@ class DevServer {
|
|
|
2437
2529
|
|
|
2438
2530
|
return enriched;
|
|
2439
2531
|
}
|
|
2532
|
+
|
|
2533
|
+
/**
|
|
2534
|
+
* Normalize real-API product for template use (no enrichment).
|
|
2535
|
+
* Aliases API keys (Description, Images, etc.) to template-expected keys,
|
|
2536
|
+
* sets url/link for dev navigation, and normalizes image items to { url, altText }.
|
|
2537
|
+
* @param {Object} p - Product from real API
|
|
2538
|
+
* @returns {Object} Normalized product (shallow copy + aliases)
|
|
2539
|
+
*/
|
|
2540
|
+
normalizeProductForRealMode(p) {
|
|
2541
|
+
if (!p || typeof p !== 'object') return p;
|
|
2542
|
+
const n = { ...p };
|
|
2543
|
+
if (n.Description != null && n.description == null) n.description = n.Description;
|
|
2544
|
+
if (n.HtmlContent != null && n.htmlContent == null) n.htmlContent = n.HtmlContent;
|
|
2545
|
+
if (n.ShortDescription != null && n.shortDescription == null) n.shortDescription = n.ShortDescription;
|
|
2546
|
+
if (n.Name != null && n.name == null) n.name = n.Name;
|
|
2547
|
+
if (n.Title != null && n.title == null) n.title = n.Title;
|
|
2548
|
+
if (n.Attributes != null && n.attributes == null) n.attributes = n.Attributes;
|
|
2549
|
+
const t1 = n.ThumbnailImage1 || n.thumbnailImage1;
|
|
2550
|
+
if (t1 != null) {
|
|
2551
|
+
const u = typeof t1 === 'string' ? t1 : (t1.url || t1.Url || t1.imageUrl || t1.ImageUrl);
|
|
2552
|
+
if (u) {
|
|
2553
|
+
const a = typeof t1 === 'string' ? (n.name || n.title) : (t1.altText || t1.AltText || n.name || n.title);
|
|
2554
|
+
n.thumbnailImage1 = { url: u, altText: a };
|
|
2555
|
+
if (!n.thumbnailImage) n.thumbnailImage = n.thumbnailImage1;
|
|
2556
|
+
} else if (!n.thumbnailImage1) n.thumbnailImage1 = t1;
|
|
2557
|
+
}
|
|
2558
|
+
let imgs = n.images || n.Images;
|
|
2559
|
+
if (Array.isArray(imgs) && imgs.length > 0) {
|
|
2560
|
+
n.images = imgs.map((img) => {
|
|
2561
|
+
if (typeof img === 'string') return { url: img, altText: n.name || n.title };
|
|
2562
|
+
const u = img.url || img.Url || img.imageUrl || img.ImageUrl;
|
|
2563
|
+
const a = img.altText || img.AltText || n.name || n.title;
|
|
2564
|
+
return u ? { url: u, altText: a } : (typeof img === 'object' ? { url: img, altText: a } : { url: String(img), altText: a });
|
|
2565
|
+
});
|
|
2566
|
+
} else if (imgs) {
|
|
2567
|
+
n.images = Array.isArray(imgs) ? imgs : [imgs];
|
|
2568
|
+
} else {
|
|
2569
|
+
const single = n.imageUrl || n.ImageUrl;
|
|
2570
|
+
if (single) n.images = [{ url: single, altText: n.name || n.title }];
|
|
2571
|
+
}
|
|
2572
|
+
const handle = (n.handle || n.slug || n.id || '').toString().trim();
|
|
2573
|
+
const h = handle && handle !== '#' ? handle.toLowerCase() : (n.productId ? `product-${n.productId}` : null);
|
|
2574
|
+
if (h) {
|
|
2575
|
+
n.url = `/products/${h}`;
|
|
2576
|
+
n.link = `/products/${h}`;
|
|
2577
|
+
}
|
|
2578
|
+
return n;
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
normalizeProductsForRealMode(products) {
|
|
2582
|
+
if (!Array.isArray(products)) return [];
|
|
2583
|
+
return products.map((p) => this.normalizeProductForRealMode(p));
|
|
2584
|
+
}
|
|
2440
2585
|
|
|
2441
2586
|
/**
|
|
2442
2587
|
* Enrich multiple products with all required data
|
|
@@ -2474,12 +2619,9 @@ class DevServer {
|
|
|
2474
2619
|
);
|
|
2475
2620
|
widgetProducts = sorted.slice(0, limit);
|
|
2476
2621
|
} else {
|
|
2477
|
-
// Default: return first N products
|
|
2478
2622
|
widgetProducts = products.slice(0, limit);
|
|
2479
2623
|
}
|
|
2480
|
-
|
|
2481
|
-
// Enrich all products with required data before returning
|
|
2482
|
-
return this.enrichProductsData(widgetProducts);
|
|
2624
|
+
return widgetProducts;
|
|
2483
2625
|
}
|
|
2484
2626
|
|
|
2485
2627
|
/**
|
|
@@ -3284,6 +3426,22 @@ class DevServer {
|
|
|
3284
3426
|
logLevel: 'silent',
|
|
3285
3427
|
timeout: 2000
|
|
3286
3428
|
}));
|
|
3429
|
+
} else if (this.mode === 'real') {
|
|
3430
|
+
const base = (process.env.O2VEND_API_BASE_URL || '').replace(/\/$/, '').replace(/\/shopfront\/api\/v2\/?$/i, '').replace(/\/webstoreapi\/?$/i, '');
|
|
3431
|
+
if (base) {
|
|
3432
|
+
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
3433
|
+
const proxyOpts = {
|
|
3434
|
+
target: base,
|
|
3435
|
+
changeOrigin: true,
|
|
3436
|
+
logLevel: 'silent',
|
|
3437
|
+
timeout: parseInt(process.env.O2VEND_API_TIMEOUT, 10) || 10000,
|
|
3438
|
+
onProxyReq: (proxyReq, req) => {
|
|
3439
|
+
if (req.headers.cookie) proxyReq.setHeader('Cookie', req.headers.cookie);
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
this.app.use('/webstoreapi', createProxyMiddleware(proxyOpts));
|
|
3443
|
+
this.app.use('/shopfront/api', createProxyMiddleware(proxyOpts));
|
|
3444
|
+
}
|
|
3287
3445
|
}
|
|
3288
3446
|
|
|
3289
3447
|
// Inject hot reload script into HTML responses
|