@o2vend/theme-cli 1.0.34 → 1.0.36
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 +750 -661
- package/lib/lib/liquid-engine.js +88 -65
- package/lib/lib/liquid-filters.js +10 -29
- package/lib/lib/mock-api-server.js +58 -144
- package/lib/lib/mock-data.js +126 -78
- package/lib/lib/webstoreapi-fetcher.js +322 -0
- package/lib/lib/widget-service.js +49 -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 = {}) {
|
|
@@ -20,6 +26,7 @@ class DevServer {
|
|
|
20
26
|
this.mode = options.mode || 'mock'; // 'mock' or 'real'
|
|
21
27
|
this.mockApiPort = options.mockApiPort || 3001;
|
|
22
28
|
this.openBrowser = options.open !== false;
|
|
29
|
+
this.debug = options.debug || process.env.O2VEND_DEBUG === 'true' || process.env.DEBUG === 'true';
|
|
23
30
|
|
|
24
31
|
this.app = express();
|
|
25
32
|
this.server = null;
|
|
@@ -130,29 +137,33 @@ class DevServer {
|
|
|
130
137
|
// Only process if it contains actual Liquid variables, not just comments
|
|
131
138
|
if (cssContent.includes('{{') && cssContent.includes('settings.')) {
|
|
132
139
|
try {
|
|
133
|
-
// Load settings from theme config (already extracts 'current' section)
|
|
134
140
|
const settings = this.loadThemeSettings();
|
|
135
|
-
|
|
136
|
-
// Process through Liquid
|
|
137
141
|
const processedCss = await this.liquid.parseAndRender(cssContent, { settings });
|
|
138
|
-
|
|
139
142
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
140
143
|
res.setHeader('Cache-Control', 'no-cache');
|
|
141
144
|
return res.send(processedCss);
|
|
142
145
|
} catch (liquidError) {
|
|
143
146
|
console.error(`[CSS] Liquid processing error for ${relativePath}:`, liquidError.message);
|
|
144
|
-
// Fall through to serve static file
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
// Serve static file if no Liquid processing needed or if processing failed
|
|
149
150
|
res.setHeader('Content-Type', 'text/css; charset=utf-8');
|
|
150
|
-
res.sendFile(
|
|
151
|
+
res.sendFile(relativePath, { root: path.join(this.themePath, 'assets') });
|
|
151
152
|
} catch (error) {
|
|
152
153
|
console.error(`[CSS] Error processing ${req.path}:`, error.message);
|
|
153
154
|
next();
|
|
154
155
|
}
|
|
155
156
|
});
|
|
157
|
+
|
|
158
|
+
// Explicit logo route so /assets/logo.png always serves correctly (avoids broken image)
|
|
159
|
+
this.app.get('/assets/logo.png', (req, res) => {
|
|
160
|
+
const logoPath = path.join(this.themePath, 'assets', 'logo.png');
|
|
161
|
+
if (!fs.existsSync(logoPath)) {
|
|
162
|
+
return res.status(404).setHeader('Content-Type', 'text/plain').end('Logo not found');
|
|
163
|
+
}
|
|
164
|
+
res.setHeader('Content-Type', 'image/png');
|
|
165
|
+
res.sendFile('logo.png', { root: path.join(this.themePath, 'assets') });
|
|
166
|
+
});
|
|
156
167
|
|
|
157
168
|
// Static files (theme assets) with proper MIME types
|
|
158
169
|
this.app.use('/assets', express.static(path.join(this.themePath, 'assets'), {
|
|
@@ -225,37 +236,36 @@ class DevServer {
|
|
|
225
236
|
|
|
226
237
|
/**
|
|
227
238
|
* Setup API client and widget service
|
|
239
|
+
* Real mode: no apiClient (use webstoreapi-fetcher only). WidgetService with null for getTemplateSlug.
|
|
240
|
+
* Mock mode: apiClient created in start() after mock API; WidgetService uses it.
|
|
228
241
|
*/
|
|
229
242
|
async setupServices() {
|
|
230
243
|
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
244
|
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'));
|
|
245
|
+
if (!baseUrl) {
|
|
246
|
+
console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_API_BASE_URL (storefront URL, e.g. https://store.myo2vend.com)'));
|
|
238
247
|
console.warn(chalk.yellow(' Falling back to mock mode'));
|
|
239
248
|
this.mode = 'mock';
|
|
240
249
|
} else {
|
|
241
|
-
this.apiClient =
|
|
250
|
+
this.apiClient = null;
|
|
251
|
+
this.widgetService = new WidgetService(null, {
|
|
252
|
+
theme: path.basename(this.themePath),
|
|
253
|
+
themePath: this.themePath
|
|
254
|
+
});
|
|
242
255
|
}
|
|
243
256
|
}
|
|
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
|
|
257
|
+
|
|
247
258
|
if (this.mode === 'mock') {
|
|
248
|
-
// Create a temporary client - will be replaced in start() method
|
|
249
259
|
this.apiClient = null;
|
|
250
260
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
261
|
+
|
|
262
|
+
if (this.mode === 'mock' || !this.widgetService) {
|
|
263
|
+
if (this.apiClient) {
|
|
264
|
+
this.widgetService = new WidgetService(this.apiClient, {
|
|
265
|
+
theme: path.basename(this.themePath),
|
|
266
|
+
themePath: this.themePath
|
|
267
|
+
});
|
|
268
|
+
}
|
|
259
269
|
}
|
|
260
270
|
|
|
261
271
|
// Create Liquid engine
|
|
@@ -414,15 +424,21 @@ class DevServer {
|
|
|
414
424
|
// Log context data for debugging
|
|
415
425
|
const widgetSections = Object.keys(context.widgets || {});
|
|
416
426
|
const widgetCounts = widgetSections.map(s => `${s}:${context.widgets[s]?.length || 0}`).join(', ');
|
|
417
|
-
console.log(`[HOME] Products: ${context.products?.length || 0}, Widgets: [${widgetCounts}], Menus: ${context.menus
|
|
427
|
+
console.log(`[HOME] Products: ${context.products?.length || 0}, Widgets: [${widgetCounts}], Menus: ${Object.keys(context.menus || {}).length}`);
|
|
418
428
|
|
|
419
429
|
// Ensure products are available
|
|
420
430
|
if (!context.products || context.products.length === 0) {
|
|
421
431
|
console.warn('[HOME] No products in context, attempting reload...');
|
|
422
432
|
try {
|
|
423
|
-
if (this.
|
|
433
|
+
if (this.mode === 'real') {
|
|
434
|
+
const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
435
|
+
context.products = res?.products || res?.Products || [];
|
|
436
|
+
context.products = this.enrichProductsData(context.products);
|
|
437
|
+
console.log(`[HOME] Reloaded ${context.products.length} products (real)`);
|
|
438
|
+
} else if (this.apiClient) {
|
|
424
439
|
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
|
|
425
440
|
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
441
|
+
context.products = this.enrichProductsData(context.products);
|
|
426
442
|
console.log(`[HOME] Reloaded ${context.products.length} products`);
|
|
427
443
|
} else {
|
|
428
444
|
console.error('[HOME] API client not available');
|
|
@@ -431,25 +447,25 @@ class DevServer {
|
|
|
431
447
|
console.error('[HOME] Failed to reload products:', error.message);
|
|
432
448
|
}
|
|
433
449
|
}
|
|
434
|
-
|
|
435
|
-
// Ensure widgets are available
|
|
450
|
+
|
|
436
451
|
if (!context.widgets || Object.keys(context.widgets).length === 0) {
|
|
437
452
|
console.warn('[HOME] No widgets in context, attempting reload...');
|
|
438
453
|
try {
|
|
439
|
-
if (this.
|
|
440
|
-
const
|
|
441
|
-
context.widgets =
|
|
442
|
-
console.log(`[HOME] Reloaded widgets
|
|
454
|
+
if (this.mode === 'real') {
|
|
455
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
456
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections('home', webstoreApi.DEFAULT_SECTIONS, opts) || { hero: [], content: [], footer: [] };
|
|
457
|
+
console.log(`[HOME] Reloaded widgets (real)`);
|
|
458
|
+
} else if (this.widgetService) {
|
|
459
|
+
context.widgets = await this.widgetService.fetchPageWidgets('home');
|
|
443
460
|
} else {
|
|
444
|
-
|
|
445
|
-
context.widgets = { hero: [], content: [], footer: [] };
|
|
461
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
446
462
|
}
|
|
447
463
|
} catch (error) {
|
|
448
464
|
console.error('[HOME] Failed to reload widgets:', error.message);
|
|
449
|
-
context.widgets = { hero: [], content: [], footer: [] };
|
|
465
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
450
466
|
}
|
|
451
467
|
}
|
|
452
|
-
|
|
468
|
+
|
|
453
469
|
const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
|
|
454
470
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
455
471
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -471,22 +487,22 @@ class DevServer {
|
|
|
471
487
|
if (categoryFilter) {
|
|
472
488
|
console.log(`[PRODUCTS PAGE] Filtering by category: ${categoryFilter}`);
|
|
473
489
|
|
|
474
|
-
|
|
475
|
-
const category = context.categories?.find(c =>
|
|
476
|
-
(c.
|
|
477
|
-
(c.
|
|
478
|
-
|
|
490
|
+
const cf = (categoryFilter || '').toLowerCase();
|
|
491
|
+
const category = context.categories?.find(c => {
|
|
492
|
+
const ch = (c.handle || '').toLowerCase();
|
|
493
|
+
const cs = (c.slug || '').toLowerCase();
|
|
494
|
+
const cn = (c.name || '').toLowerCase();
|
|
495
|
+
const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
496
|
+
return ch === cf || cs === cf || cn === cf || cnNorm === cf;
|
|
497
|
+
});
|
|
479
498
|
|
|
480
499
|
if (category) {
|
|
481
500
|
console.log(`[PRODUCTS PAGE] Found category: ${category.name} (ID: ${category.id})`);
|
|
482
501
|
|
|
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
|
|
502
|
+
const filtered = context.products.filter(p => this.productMatchesCategory(p, category));
|
|
503
|
+
const filteredProducts = this.mode === 'real'
|
|
504
|
+
? filtered
|
|
505
|
+
: filtered.map(p => this.enrichProductData(p));
|
|
490
506
|
|
|
491
507
|
// CRITICAL: Set both context.products and context.collection.products to the SAME enriched array
|
|
492
508
|
// This ensures they always reference the same objects (like widgets do)
|
|
@@ -507,14 +523,11 @@ class DevServer {
|
|
|
507
523
|
}
|
|
508
524
|
}
|
|
509
525
|
|
|
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}`);
|
|
526
|
+
if (context.products && context.products.length > 0 && this.mode !== 'real') {
|
|
527
|
+
if (!context.products[0].url && !context.products[0].link) {
|
|
528
|
+
console.log(`[PRODUCTS PAGE] Products need enrichment - enriching now...`);
|
|
529
|
+
context.products = this.enrichProductsData(context.products);
|
|
530
|
+
}
|
|
518
531
|
}
|
|
519
532
|
|
|
520
533
|
// Debug: Log sample product data to verify enrichment
|
|
@@ -526,7 +539,7 @@ class DevServer {
|
|
|
526
539
|
price: sample.price || sample.sellingPrice,
|
|
527
540
|
stock: sample.stock,
|
|
528
541
|
inStock: sample.inStock,
|
|
529
|
-
hasImage: !!(sample.imageUrl || sample.
|
|
542
|
+
hasImage: !!(sample.imageUrl || sample.thumbnailImage)
|
|
530
543
|
});
|
|
531
544
|
}
|
|
532
545
|
|
|
@@ -535,7 +548,7 @@ class DevServer {
|
|
|
535
548
|
console.log(` - Products count: ${context.products?.length || 0}`);
|
|
536
549
|
console.log(` - Widgets sections: ${Object.keys(context.widgets || {}).join(', ') || 'none'}`);
|
|
537
550
|
console.log(` - Widgets counts: ${Object.keys(context.widgets || {}).map(s => `${s}:${context.widgets[s]?.length || 0}`).join(', ') || 'none'}`);
|
|
538
|
-
console.log(` - Menus count: ${context.menus
|
|
551
|
+
console.log(` - Menus count: ${Object.keys(context.menus || {}).length}`);
|
|
539
552
|
console.log(` - Categories count: ${context.categories?.length || 0}`);
|
|
540
553
|
console.log(` - Brands count: ${context.brands?.length || 0}`);
|
|
541
554
|
|
|
@@ -543,23 +556,26 @@ class DevServer {
|
|
|
543
556
|
if (!context.products || context.products.length === 0) {
|
|
544
557
|
console.warn('[PRODUCTS PAGE] ⚠️ No products in context, attempting to reload...');
|
|
545
558
|
try {
|
|
546
|
-
const queryParams = {};
|
|
559
|
+
const queryParams = { limit: 50, offset: 0 };
|
|
547
560
|
if (categoryFilter) {
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
(c.
|
|
551
|
-
(c.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
}
|
|
561
|
+
const cf = (categoryFilter || '').toLowerCase();
|
|
562
|
+
const cat = context.categories?.find(c => {
|
|
563
|
+
const ch = (c.handle || '').toLowerCase();
|
|
564
|
+
const cs = (c.slug || '').toLowerCase();
|
|
565
|
+
const cn = (c.name || '').toLowerCase();
|
|
566
|
+
const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
567
|
+
return ch === cf || cs === cf || cn === cf || cnNorm === cf;
|
|
568
|
+
});
|
|
569
|
+
if (cat) queryParams.categoryId = cat.id;
|
|
570
|
+
}
|
|
571
|
+
if (this.mode === 'real') {
|
|
572
|
+
const res = await webstoreApi.fetchProducts(queryParams, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
573
|
+
context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
|
|
574
|
+
} else if (this.apiClient) {
|
|
575
|
+
const productsResponse = await this.apiClient.getProducts(queryParams);
|
|
576
|
+
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
577
|
+
context.products = this.enrichProductsData(context.products);
|
|
556
578
|
}
|
|
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
579
|
console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.products.length} products`);
|
|
564
580
|
if (context.products.length > 0) {
|
|
565
581
|
const sample = context.products[0];
|
|
@@ -579,7 +595,7 @@ class DevServer {
|
|
|
579
595
|
if (context.products.length > 0) {
|
|
580
596
|
const sampleProduct = context.products[0];
|
|
581
597
|
const hasStock = 'stock' in sampleProduct || 'quantity' in sampleProduct;
|
|
582
|
-
const hasImage = sampleProduct.
|
|
598
|
+
const hasImage = sampleProduct.thumbnailImage || sampleProduct.imageUrl || (sampleProduct.images && sampleProduct.images.length > 0);
|
|
583
599
|
const hasUrl = sampleProduct.url || sampleProduct.link;
|
|
584
600
|
// Use nullish coalescing to show 0 values correctly
|
|
585
601
|
const stockValue = sampleProduct.stock ?? sampleProduct.quantity ?? 'N/A';
|
|
@@ -594,37 +610,25 @@ class DevServer {
|
|
|
594
610
|
}
|
|
595
611
|
}
|
|
596
612
|
|
|
597
|
-
// Ensure widgets are available (they should be from buildContext, but double-check)
|
|
598
613
|
if (!context.widgets || Object.keys(context.widgets).length === 0) {
|
|
599
|
-
console.warn('[PRODUCTS PAGE]
|
|
614
|
+
console.warn('[PRODUCTS PAGE] No widgets in context, attempting reload...');
|
|
600
615
|
try {
|
|
601
|
-
if (this.
|
|
602
|
-
const
|
|
603
|
-
context.widgets =
|
|
604
|
-
|
|
616
|
+
if (this.mode === 'real') {
|
|
617
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
618
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections('products', webstoreApi.DEFAULT_SECTIONS, opts) || { header: [], hero: [], content: [], footer: [] };
|
|
619
|
+
} else if (this.widgetService) {
|
|
620
|
+
context.widgets = await this.widgetService.fetchPageWidgets('products');
|
|
605
621
|
} else {
|
|
606
|
-
|
|
607
|
-
context.widgets = { hero: [], content: [], footer: [], header: [] };
|
|
622
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
608
623
|
}
|
|
609
624
|
} catch (error) {
|
|
610
|
-
console.error('[PRODUCTS PAGE]
|
|
611
|
-
context.widgets = {
|
|
625
|
+
console.error('[PRODUCTS PAGE] Failed to reload widgets:', error.message);
|
|
626
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
612
627
|
}
|
|
613
628
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
console.warn('[PRODUCTS PAGE] ⚠️ No menus in context, attempting to reload...');
|
|
618
|
-
try {
|
|
619
|
-
const axios = require('axios');
|
|
620
|
-
const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
|
|
621
|
-
const menusData = menusResponse.data;
|
|
622
|
-
context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
|
|
623
|
-
console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus`);
|
|
624
|
-
} catch (error) {
|
|
625
|
-
console.error('[PRODUCTS PAGE] ❌ Failed to reload menus:', error.message);
|
|
626
|
-
context.menus = [];
|
|
627
|
-
}
|
|
629
|
+
|
|
630
|
+
if (!context.menus || Object.keys(context.menus).length === 0) {
|
|
631
|
+
console.warn('[PRODUCTS PAGE] No menus in context');
|
|
628
632
|
}
|
|
629
633
|
|
|
630
634
|
// Ensure collection.products is set for products template
|
|
@@ -663,9 +667,9 @@ class DevServer {
|
|
|
663
667
|
stockQuantity: sample.stockQuantity !== undefined ? sample.stockQuantity : 'MISSING',
|
|
664
668
|
inStock: sample.inStock !== undefined ? sample.inStock : 'MISSING',
|
|
665
669
|
available: sample.available !== undefined ? sample.available : 'MISSING',
|
|
666
|
-
hasImage: !!(sample.imageUrl || sample.
|
|
667
|
-
imageUrl: sample.imageUrl || sample.
|
|
668
|
-
|
|
670
|
+
hasImage: !!(sample.imageUrl || sample.thumbnailImage),
|
|
671
|
+
imageUrl: sample.imageUrl || sample.thumbnailImage?.url || 'MISSING',
|
|
672
|
+
hasThumbnailImage: !!sample.thumbnailImage,
|
|
669
673
|
hasImagesArray: !!(sample.images && sample.images.length > 0)
|
|
670
674
|
});
|
|
671
675
|
} else {
|
|
@@ -687,10 +691,10 @@ class DevServer {
|
|
|
687
691
|
title: firstProduct.title || firstProduct.name,
|
|
688
692
|
url: firstProduct.url || firstProduct.link,
|
|
689
693
|
price: firstProduct.price || firstProduct.sellingPrice,
|
|
690
|
-
|
|
694
|
+
stockQuantity: firstProduct.stockQuantity,
|
|
691
695
|
inStock: firstProduct.inStock,
|
|
692
|
-
hasImage: !!(firstProduct.imageUrl || firstProduct.
|
|
693
|
-
imageUrl: firstProduct.imageUrl || firstProduct.
|
|
696
|
+
hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage),
|
|
697
|
+
imageUrl: firstProduct.imageUrl || firstProduct.thumbnailImage
|
|
694
698
|
});
|
|
695
699
|
}
|
|
696
700
|
if (context.collection) {
|
|
@@ -842,62 +846,32 @@ class DevServer {
|
|
|
842
846
|
}
|
|
843
847
|
});
|
|
844
848
|
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
try {
|
|
848
|
-
const context = await this.buildContext(req, 'collections');
|
|
849
|
-
// Try categories.liquid first (as per webstore), then collections.liquid, then collection.liquid
|
|
850
|
-
let html;
|
|
851
|
-
try {
|
|
852
|
-
html = await renderWithLayout(this.liquid, 'templates/categories', context, this.themePath);
|
|
853
|
-
} catch (error) {
|
|
854
|
-
if (error.message && error.message.includes('Template not found')) {
|
|
855
|
-
try {
|
|
856
|
-
html = await renderWithLayout(this.liquid, 'templates/collections', context, this.themePath);
|
|
857
|
-
} catch (error2) {
|
|
858
|
-
html = await renderWithLayout(this.liquid, 'templates/collection', context, this.themePath);
|
|
859
|
-
}
|
|
860
|
-
} else {
|
|
861
|
-
throw error;
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
865
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
866
|
-
res.send(html);
|
|
867
|
-
} catch (error) {
|
|
868
|
-
next(error);
|
|
869
|
-
}
|
|
849
|
+
this.app.get('/collections', (req, res) => {
|
|
850
|
+
res.redirect(301, '/categories');
|
|
870
851
|
});
|
|
871
852
|
|
|
872
853
|
// Collection detail page - Try categories first, then collection
|
|
873
854
|
this.app.get('/collections/:handle', async (req, res, next) => {
|
|
874
855
|
try {
|
|
875
856
|
const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
|
|
876
|
-
|
|
877
|
-
const category = context.categories?.find(c =>
|
|
878
|
-
|
|
879
|
-
c.
|
|
880
|
-
|
|
857
|
+
const h = (req.params.handle || '').toLowerCase();
|
|
858
|
+
const category = context.categories?.find(c => {
|
|
859
|
+
const ch = (c.handle || '').toLowerCase();
|
|
860
|
+
const cs = (c.slug || '').toLowerCase();
|
|
861
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
862
|
+
return ch === h || cs === h || cn === h;
|
|
863
|
+
});
|
|
881
864
|
if (category) {
|
|
882
865
|
context.category = category;
|
|
883
866
|
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
|
-
|
|
867
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
868
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
893
869
|
context.collection.products = filteredProducts;
|
|
894
870
|
context.collection.totalProducts = filteredProducts.length;
|
|
895
|
-
context.products = filteredProducts;
|
|
896
|
-
|
|
871
|
+
context.products = filteredProducts;
|
|
897
872
|
console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
|
|
898
873
|
} else {
|
|
899
|
-
|
|
900
|
-
context.products = this.enrichProductsData(context.products || []);
|
|
874
|
+
if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
|
|
901
875
|
context.collection = context.collection || {};
|
|
902
876
|
context.collection.products = context.products;
|
|
903
877
|
}
|
|
@@ -956,30 +930,24 @@ class DevServer {
|
|
|
956
930
|
this.app.get('/categories/:handle', async (req, res, next) => {
|
|
957
931
|
try {
|
|
958
932
|
const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
c.
|
|
962
|
-
|
|
933
|
+
const h = (req.params.handle || '').toLowerCase();
|
|
934
|
+
const category = context.categories?.find(c => {
|
|
935
|
+
const ch = (c.handle || '').toLowerCase();
|
|
936
|
+
const cs = (c.slug || '').toLowerCase();
|
|
937
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
938
|
+
return ch === h || cs === h || cn === h;
|
|
939
|
+
});
|
|
963
940
|
if (category) {
|
|
964
941
|
context.category = category;
|
|
965
942
|
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
|
-
|
|
943
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
944
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
975
945
|
context.collection.products = filteredProducts;
|
|
976
946
|
context.collection.totalProducts = filteredProducts.length;
|
|
977
|
-
context.products = filteredProducts;
|
|
978
|
-
|
|
947
|
+
context.products = filteredProducts;
|
|
979
948
|
console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
|
|
980
949
|
} else {
|
|
981
|
-
|
|
982
|
-
context.products = this.enrichProductsData(context.products || []);
|
|
950
|
+
if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
|
|
983
951
|
context.collection = context.collection || {};
|
|
984
952
|
context.collection.products = context.products;
|
|
985
953
|
}
|
|
@@ -1107,41 +1075,38 @@ class DevServer {
|
|
|
1107
1075
|
try {
|
|
1108
1076
|
const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
|
|
1109
1077
|
|
|
1110
|
-
// Load page content
|
|
1078
|
+
// Load page content
|
|
1111
1079
|
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) {
|
|
1080
|
+
if (this.mode === 'real') {
|
|
1081
|
+
const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1082
|
+
if (pageData) {
|
|
1129
1083
|
context.page = {
|
|
1130
1084
|
...context.page,
|
|
1131
|
-
...
|
|
1132
|
-
body_html:
|
|
1133
|
-
content:
|
|
1085
|
+
...pageData,
|
|
1086
|
+
body_html: pageData.content || pageData.body_html,
|
|
1087
|
+
content: pageData.content || pageData.body_html
|
|
1134
1088
|
};
|
|
1135
1089
|
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
}
|
|
1090
|
+
} else {
|
|
1091
|
+
const axios = require('axios');
|
|
1092
|
+
try {
|
|
1093
|
+
const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
|
|
1094
|
+
if (pageResponse.data) {
|
|
1095
|
+
context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content || pageResponse.data.htmlContent, content: pageResponse.data.content || pageResponse.data.htmlContent };
|
|
1096
|
+
}
|
|
1097
|
+
} catch (e) {
|
|
1098
|
+
const fallback = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
|
|
1099
|
+
if (fallback.data) {
|
|
1100
|
+
context.page = { ...context.page, ...fallback.data, body_html: fallback.data.content, content: fallback.data.content };
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1144
1103
|
}
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
console.warn(`[PAGE] Failed to load page for ${req.params.handle}:`, error.message);
|
|
1106
|
+
}
|
|
1107
|
+
if (!context.page.body_html && !context.page.content) {
|
|
1108
|
+
const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
|
|
1109
|
+
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
1110
|
}
|
|
1146
1111
|
|
|
1147
1112
|
// Try page.liquid template first, then fallback to rendering raw HTML
|
|
@@ -1168,30 +1133,27 @@ class DevServer {
|
|
|
1168
1133
|
try {
|
|
1169
1134
|
const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
|
|
1170
1135
|
|
|
1171
|
-
// Load page content from mock API
|
|
1172
1136
|
try {
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1137
|
+
if (this.mode === 'real') {
|
|
1138
|
+
const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1139
|
+
if (pageData) {
|
|
1140
|
+
context.page = { ...context.page, ...pageData, body_html: pageData.content || pageData.body_html, content: pageData.content || pageData.body_html };
|
|
1141
|
+
}
|
|
1142
|
+
} else {
|
|
1143
|
+
const axios = require('axios');
|
|
1144
|
+
const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
|
|
1145
|
+
if (pageResponse.data) {
|
|
1146
|
+
context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content, content: pageResponse.data.content };
|
|
1147
|
+
}
|
|
1182
1148
|
}
|
|
1183
1149
|
} catch (error) {
|
|
1184
1150
|
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
1151
|
}
|
|
1194
|
-
|
|
1152
|
+
if (!context.page.body_html && !context.page.content) {
|
|
1153
|
+
const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
|
|
1154
|
+
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>` };
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1195
1157
|
const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
|
|
1196
1158
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1197
1159
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -1201,6 +1163,55 @@ class DevServer {
|
|
|
1201
1163
|
}
|
|
1202
1164
|
});
|
|
1203
1165
|
|
|
1166
|
+
this.app.get('/dev/context', async (req, res, next) => {
|
|
1167
|
+
try {
|
|
1168
|
+
const pageType = req.query.page || 'home';
|
|
1169
|
+
const context = await this.buildContext(req, pageType);
|
|
1170
|
+
const debugOutput = {
|
|
1171
|
+
mode: this.mode,
|
|
1172
|
+
pageType,
|
|
1173
|
+
shopKeys: Object.keys(context.shop || {}),
|
|
1174
|
+
shop: context.shop,
|
|
1175
|
+
store: context.store,
|
|
1176
|
+
menusKeys: Object.keys(context.menus || {}),
|
|
1177
|
+
mainMenu: context.mainMenu ? { name: context.mainMenu.name, itemCount: context.mainMenu.items?.length } : null,
|
|
1178
|
+
widgetSections: Object.fromEntries(
|
|
1179
|
+
Object.entries(context.widgets || {}).map(([k, v]) =>
|
|
1180
|
+
[k, Array.isArray(v) ? v.map(w => ({ type: w.type, id: w.id, template_path: w.template_path })) : v]
|
|
1181
|
+
)
|
|
1182
|
+
),
|
|
1183
|
+
productsCount: (context.products || []).length,
|
|
1184
|
+
categoriesCount: (context.categories || []).length,
|
|
1185
|
+
brandsCount: (context.brands || []).length,
|
|
1186
|
+
cart: context.cart,
|
|
1187
|
+
customer: context.customer,
|
|
1188
|
+
request: context.request,
|
|
1189
|
+
deliveryZone: context.deliveryZone,
|
|
1190
|
+
analytics: context.analytics,
|
|
1191
|
+
settingsKeys: Object.keys(context.settings || {}),
|
|
1192
|
+
sampleProduct: (context.products || [])[0] ? {
|
|
1193
|
+
id: context.products[0].id,
|
|
1194
|
+
slug: context.products[0].slug,
|
|
1195
|
+
url: context.products[0].url,
|
|
1196
|
+
stockQuantity: context.products[0].stockQuantity,
|
|
1197
|
+
inStock: context.products[0].inStock,
|
|
1198
|
+
available: context.products[0].available,
|
|
1199
|
+
prices: context.products[0].prices,
|
|
1200
|
+
thumbnailImage: context.products[0].thumbnailImage
|
|
1201
|
+
} : null,
|
|
1202
|
+
sampleCategory: (context.categories || [])[0] ? {
|
|
1203
|
+
id: context.categories[0].id,
|
|
1204
|
+
slug: context.categories[0].slug,
|
|
1205
|
+
handle: context.categories[0].handle,
|
|
1206
|
+
thumbnailImage: context.categories[0].thumbnailImage
|
|
1207
|
+
} : null
|
|
1208
|
+
};
|
|
1209
|
+
res.json(debugOutput);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
next(error);
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1204
1215
|
// Development Dashboard - List all theme components
|
|
1205
1216
|
this.app.get('/dev', async (req, res, next) => {
|
|
1206
1217
|
try {
|
|
@@ -1249,10 +1260,13 @@ class DevServer {
|
|
|
1249
1260
|
const widgetType = req.params.type.replace(/\.liquid$/, '');
|
|
1250
1261
|
const context = await this.buildContext(req, 'home');
|
|
1251
1262
|
|
|
1252
|
-
// Ensure products are loaded for widget context (needed for product widgets)
|
|
1253
1263
|
if (!context.products || context.products.length === 0) {
|
|
1254
1264
|
try {
|
|
1255
|
-
if (this.
|
|
1265
|
+
if (this.mode === 'real') {
|
|
1266
|
+
const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
|
|
1267
|
+
context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
|
|
1268
|
+
console.log(`[DEV WIDGET] Loaded ${context.products.length} products (real)`);
|
|
1269
|
+
} else if (this.apiClient) {
|
|
1256
1270
|
const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
|
|
1257
1271
|
context.products = productsResponse.products || productsResponse.data?.products || [];
|
|
1258
1272
|
console.log(`[DEV WIDGET] Loaded ${context.products.length} products for widget context`);
|
|
@@ -1408,32 +1422,22 @@ class DevServer {
|
|
|
1408
1422
|
// Build context to get categories, brands, and products
|
|
1409
1423
|
const context = await this.buildContext(req, 'collection', { collectionHandle: handle });
|
|
1410
1424
|
|
|
1411
|
-
// Try to find category by handle (case-insensitive)
|
|
1412
|
-
const
|
|
1413
|
-
|
|
1414
|
-
(c.
|
|
1415
|
-
|
|
1425
|
+
// Try to find category by handle or slug (case-insensitive)
|
|
1426
|
+
const h = handle.toLowerCase();
|
|
1427
|
+
const category = context.categories?.find(c => {
|
|
1428
|
+
const ch = (c.handle || '').toLowerCase();
|
|
1429
|
+
const cs = (c.slug || '').toLowerCase();
|
|
1430
|
+
const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
|
|
1431
|
+
return ch === h || cs === h || cn === h;
|
|
1432
|
+
});
|
|
1416
1433
|
|
|
1417
1434
|
if (category) {
|
|
1418
|
-
// Found a category - render products list (NOT categories list)
|
|
1419
1435
|
context.category = category;
|
|
1420
1436
|
context.collection = category;
|
|
1421
|
-
|
|
1422
1437
|
console.log(`[ROOT COLLECTION] Found category: ${category.name} (ID: ${category.id})`);
|
|
1423
1438
|
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
|
-
|
|
1439
|
+
const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
|
|
1440
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
1437
1441
|
console.log(`[ROOT COLLECTION] Filtered products count: ${filteredProducts.length}`);
|
|
1438
1442
|
|
|
1439
1443
|
// Log sample enriched products
|
|
@@ -1446,8 +1450,8 @@ class DevServer {
|
|
|
1446
1450
|
price: product.price || product.sellingPrice,
|
|
1447
1451
|
stock: product.stock,
|
|
1448
1452
|
inStock: product.inStock,
|
|
1449
|
-
hasImage: !!(product.imageUrl || product.
|
|
1450
|
-
imageUrl: product.imageUrl || product.
|
|
1453
|
+
hasImage: !!(product.imageUrl || product.thumbnailImage),
|
|
1454
|
+
imageUrl: product.imageUrl || product.thumbnailImage?.url
|
|
1451
1455
|
});
|
|
1452
1456
|
});
|
|
1453
1457
|
}
|
|
@@ -1476,8 +1480,8 @@ class DevServer {
|
|
|
1476
1480
|
price: sample.price || sample.sellingPrice,
|
|
1477
1481
|
stock: sample.stock,
|
|
1478
1482
|
inStock: sample.inStock,
|
|
1479
|
-
hasImage: !!(sample.imageUrl || sample.
|
|
1480
|
-
imageUrl: sample.imageUrl || sample.
|
|
1483
|
+
hasImage: !!(sample.imageUrl || sample.thumbnailImage),
|
|
1484
|
+
imageUrl: sample.imageUrl || sample.thumbnailImage?.url,
|
|
1481
1485
|
allKeys: Object.keys(sample).slice(0, 20)
|
|
1482
1486
|
});
|
|
1483
1487
|
} else {
|
|
@@ -1502,9 +1506,9 @@ class DevServer {
|
|
|
1502
1506
|
title: firstProduct.title || firstProduct.name,
|
|
1503
1507
|
url: firstProduct.url || firstProduct.link,
|
|
1504
1508
|
price: firstProduct.price || firstProduct.sellingPrice,
|
|
1505
|
-
|
|
1509
|
+
stockQuantity: firstProduct.stockQuantity,
|
|
1506
1510
|
inStock: firstProduct.inStock,
|
|
1507
|
-
hasImage: !!(firstProduct.imageUrl || firstProduct.
|
|
1511
|
+
hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage)
|
|
1508
1512
|
});
|
|
1509
1513
|
}
|
|
1510
1514
|
|
|
@@ -1536,17 +1540,13 @@ class DevServer {
|
|
|
1536
1540
|
);
|
|
1537
1541
|
|
|
1538
1542
|
if (brand) {
|
|
1539
|
-
// Found a brand - render products list
|
|
1540
1543
|
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));
|
|
1544
|
+
const filtered = (context.products || []).filter(p =>
|
|
1545
|
+
p.brandId === brand.id ||
|
|
1546
|
+
String(p.brandId).toLowerCase() === String(brand.id).toLowerCase() ||
|
|
1547
|
+
(p.brand && p.brand.toLowerCase() === brand.name.toLowerCase())
|
|
1548
|
+
);
|
|
1549
|
+
const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
|
|
1550
1550
|
|
|
1551
1551
|
context.brand.products = filteredProducts;
|
|
1552
1552
|
context.products = filteredProducts;
|
|
@@ -1604,28 +1604,45 @@ class DevServer {
|
|
|
1604
1604
|
}
|
|
1605
1605
|
}
|
|
1606
1606
|
|
|
1607
|
-
// Not
|
|
1608
|
-
|
|
1607
|
+
// Not a category, brand, or product -- try as a CMS page
|
|
1608
|
+
{
|
|
1609
|
+
const pageContext = await this.buildContext(req, 'page', { pageHandle: handle });
|
|
1610
|
+
pageContext.page = { title: handle.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()), handle: handle };
|
|
1611
|
+
let html;
|
|
1612
|
+
const pageTemplates = ['templates/page', 'templates/pages'];
|
|
1613
|
+
for (const template of pageTemplates) {
|
|
1614
|
+
try {
|
|
1615
|
+
html = await renderWithLayout(this.liquid, template, pageContext, this.themePath);
|
|
1616
|
+
break;
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
if (!error.message?.includes('Template not found')) throw error;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (html) {
|
|
1622
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
1623
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
1624
|
+
return res.send(html);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// Not found - 404
|
|
1629
|
+
res.status(404).send(`<!DOCTYPE html><html><head><title>Page Not Found</title></head><body><h1>Page Not Found</h1><p>The page "${handle}" was not found.</p><p><a href="/">Back to Home</a></p></body></html>`);
|
|
1609
1630
|
} catch (error) {
|
|
1610
|
-
|
|
1631
|
+
console.error(`[CATCH-ALL] Error handling /${req.params?.handle}:`, error.message);
|
|
1611
1632
|
next(error);
|
|
1612
1633
|
}
|
|
1613
1634
|
});
|
|
1614
1635
|
|
|
1615
|
-
// Favicon handler
|
|
1636
|
+
// Favicon handler: serve from theme assets if present, else 204
|
|
1616
1637
|
this.app.get('/favicon.ico', (req, res) => {
|
|
1617
|
-
|
|
1638
|
+
const faviconPath = path.join(this.themePath, 'assets', 'favicon.ico');
|
|
1639
|
+
if (fs.existsSync(faviconPath)) {
|
|
1640
|
+
res.setHeader('Content-Type', 'image/x-icon');
|
|
1641
|
+
return res.sendFile('favicon.ico', { root: path.join(this.themePath, 'assets') });
|
|
1642
|
+
}
|
|
1643
|
+
res.status(204).end();
|
|
1618
1644
|
});
|
|
1619
1645
|
|
|
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
1646
|
// Error handler
|
|
1630
1647
|
this.app.use((error, req, res, next) => {
|
|
1631
1648
|
// Ignore request aborted errors - they're harmless (client cancelled request)
|
|
@@ -1695,13 +1712,10 @@ class DevServer {
|
|
|
1695
1712
|
* - If template expects 'quantity' instead of 'stock', consider adding both fields
|
|
1696
1713
|
*
|
|
1697
1714
|
* 2. WIDGETS LOADING:
|
|
1698
|
-
* - Widgets are loaded via widgetService.
|
|
1715
|
+
* - Widgets are loaded via widgetService.fetchPageWidgets(pageId)
|
|
1699
1716
|
* - Widgets are organized by section (header, hero, content, footer)
|
|
1700
|
-
* - Widgets
|
|
1701
|
-
* -
|
|
1702
|
-
* a) widgetService is initialized (should be created in setupServices)
|
|
1703
|
-
* b) Mock API is returning widgets correctly
|
|
1704
|
-
* c) Widget service is properly organizing widgets by section
|
|
1717
|
+
* - Widgets are filtered by pageId to match production behavior
|
|
1718
|
+
* - pageId is derived from pageType: home->home, product->product, collection->category, etc.
|
|
1705
1719
|
*
|
|
1706
1720
|
* 3. MENUS LOADING:
|
|
1707
1721
|
* - Menus are loaded from /webstoreapi/menus endpoint
|
|
@@ -1716,8 +1730,10 @@ class DevServer {
|
|
|
1716
1730
|
* @returns {Promise<Object>} Template context
|
|
1717
1731
|
*/
|
|
1718
1732
|
async buildContext(req, pageType, extra = {}) {
|
|
1733
|
+
const hostname = `${this.host}:${this.port}`;
|
|
1719
1734
|
const context = {
|
|
1720
1735
|
shop: {},
|
|
1736
|
+
store: {},
|
|
1721
1737
|
tenant: {},
|
|
1722
1738
|
page: {
|
|
1723
1739
|
type: pageType,
|
|
@@ -1727,29 +1743,40 @@ class DevServer {
|
|
|
1727
1743
|
collections: [],
|
|
1728
1744
|
categories: [],
|
|
1729
1745
|
brands: [],
|
|
1730
|
-
cart: {
|
|
1746
|
+
cart: {
|
|
1731
1747
|
id: 'mock-cart-1',
|
|
1732
|
-
items: [],
|
|
1733
|
-
total: 0,
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
shipping: 0
|
|
1739
|
-
},
|
|
1740
|
-
// Mock authenticated customer for dev mode (allows add-to-cart without login)
|
|
1741
|
-
customer: {
|
|
1742
|
-
id: 'mock-customer-1',
|
|
1743
|
-
isAuthenticated: true,
|
|
1744
|
-
firstName: 'Test',
|
|
1745
|
-
lastName: 'User',
|
|
1746
|
-
email: 'test@example.com',
|
|
1747
|
-
phone: '+1234567890',
|
|
1748
|
-
name: 'Test User'
|
|
1748
|
+
items: [],
|
|
1749
|
+
total: 0,
|
|
1750
|
+
subTotal: 0,
|
|
1751
|
+
taxAmount: 0,
|
|
1752
|
+
shippingAmount: 0,
|
|
1753
|
+
itemCount: 0
|
|
1749
1754
|
},
|
|
1755
|
+
customer: null,
|
|
1750
1756
|
widgets: {},
|
|
1751
1757
|
settings: {},
|
|
1752
|
-
menus:
|
|
1758
|
+
menus: {},
|
|
1759
|
+
mainMenu: null,
|
|
1760
|
+
request: {
|
|
1761
|
+
path: req.path,
|
|
1762
|
+
url: req.url,
|
|
1763
|
+
host: hostname
|
|
1764
|
+
},
|
|
1765
|
+
deliveryZone: {
|
|
1766
|
+
enabled: false,
|
|
1767
|
+
mode: 0,
|
|
1768
|
+
current: null,
|
|
1769
|
+
zipcodeSearchEnabled: false,
|
|
1770
|
+
defaultZipcode: null,
|
|
1771
|
+
showModal: false
|
|
1772
|
+
},
|
|
1773
|
+
analytics: {
|
|
1774
|
+
enabled: false,
|
|
1775
|
+
baseUrl: null,
|
|
1776
|
+
siteId: null,
|
|
1777
|
+
visitorId: null
|
|
1778
|
+
},
|
|
1779
|
+
content_for_header: '',
|
|
1753
1780
|
...extra
|
|
1754
1781
|
};
|
|
1755
1782
|
|
|
@@ -1777,63 +1804,54 @@ class DevServer {
|
|
|
1777
1804
|
// Use mock API (API client points to mock API via proxy)
|
|
1778
1805
|
try {
|
|
1779
1806
|
const storeInfo = await this.apiClient.getStoreInfo(true);
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1807
|
+
const si = storeInfo || {};
|
|
1808
|
+
const storeSettings = si.settings || {};
|
|
1809
|
+
context.shop = {
|
|
1810
|
+
name: si.name || 'My Store',
|
|
1811
|
+
domain: si.domain || hostname,
|
|
1812
|
+
currency: storeSettings.currency || 'USD',
|
|
1813
|
+
currencySymbol: storeSettings.currencySymbol || '$',
|
|
1814
|
+
timezone: storeSettings.timezone || si.timezone || 'America/New_York',
|
|
1815
|
+
language: storeSettings.language || si.language || 'en',
|
|
1816
|
+
logo: si.logoUrl || storeSettings.logo || '/assets/logo.png',
|
|
1817
|
+
favicon: si.favouriteIconUrl || storeSettings.favicon || '/favicon.ico',
|
|
1818
|
+
id: si.id || 1,
|
|
1819
|
+
identifier: si.identifier || 'store',
|
|
1820
|
+
companyName: si.companyName || si.name || 'My Store',
|
|
1821
|
+
companyAddress: si.companyAddress || '',
|
|
1822
|
+
companyPhoneNumber: si.companyPhoneNumber || si.phone || '',
|
|
1823
|
+
companyEmail: si.companyEmail || si.email || '',
|
|
1824
|
+
settings: storeSettings
|
|
1825
|
+
};
|
|
1826
|
+
context.store = {
|
|
1827
|
+
id: context.shop.id,
|
|
1828
|
+
identifier: context.shop.identifier,
|
|
1829
|
+
name: context.shop.name,
|
|
1830
|
+
settings: storeSettings,
|
|
1831
|
+
companyName: context.shop.companyName,
|
|
1832
|
+
companyAddress: context.shop.companyAddress,
|
|
1833
|
+
companyPhoneNumber: context.shop.companyPhoneNumber,
|
|
1834
|
+
companyEmail: context.shop.companyEmail
|
|
1835
|
+
};
|
|
1836
|
+
context.tenant = {
|
|
1837
|
+
id: si.id || 1,
|
|
1838
|
+
name: context.shop.name,
|
|
1839
|
+
settings: storeSettings
|
|
1785
1840
|
};
|
|
1786
1841
|
|
|
1787
|
-
//
|
|
1788
|
-
|
|
1789
|
-
|
|
1842
|
+
// Load widgets filtered by page type (matches production behavior)
|
|
1843
|
+
const pageIdMap = { home: 'home', product: 'product', collection: 'category', category: 'category', products: 'products', categories: 'categories', brand: 'brand', checkout: 'checkout', page: 'home' };
|
|
1844
|
+
const widgetPageId = pageIdMap[pageType] || 'home';
|
|
1790
1845
|
if (this.widgetService) {
|
|
1791
1846
|
try {
|
|
1792
|
-
console.log(`[CONTEXT] Loading widgets for page type: ${pageType}`);
|
|
1793
|
-
|
|
1794
|
-
context.widgets = widgets || {
|
|
1795
|
-
hero: [],
|
|
1796
|
-
products: [],
|
|
1797
|
-
footer: [],
|
|
1798
|
-
content: [],
|
|
1799
|
-
header: []
|
|
1800
|
-
};
|
|
1801
|
-
// Log widget counts by section
|
|
1802
|
-
const widgetCounts = Object.keys(context.widgets).map(section =>
|
|
1803
|
-
`${section}: ${context.widgets[section]?.length || 0}`
|
|
1804
|
-
).join(', ');
|
|
1805
|
-
console.log(`[CONTEXT] ✅ Widgets loaded: ${widgetCounts}`);
|
|
1806
|
-
|
|
1807
|
-
// DEBUG: Log detailed widget information
|
|
1808
|
-
Object.keys(context.widgets).forEach(section => {
|
|
1809
|
-
if (context.widgets[section] && context.widgets[section].length > 0) {
|
|
1810
|
-
console.log(`[CONTEXT DEBUG] Section '${section}' has ${context.widgets[section].length} widget(s):`);
|
|
1811
|
-
context.widgets[section].slice(0, 3).forEach((w, idx) => {
|
|
1812
|
-
console.log(` - Widget ${idx + 1}: ${w.type || 'Unknown'} (ID: ${w.id || 'no-id'})`);
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
});
|
|
1847
|
+
console.log(`[CONTEXT] Loading widgets for page type: ${pageType} (pageId: ${widgetPageId})`);
|
|
1848
|
+
context.widgets = await this.widgetService.fetchPageWidgets(widgetPageId);
|
|
1816
1849
|
} catch (error) {
|
|
1817
|
-
console.error('[CONTEXT]
|
|
1818
|
-
|
|
1819
|
-
context.widgets = {
|
|
1820
|
-
hero: [],
|
|
1821
|
-
products: [],
|
|
1822
|
-
footer: [],
|
|
1823
|
-
content: [],
|
|
1824
|
-
header: []
|
|
1825
|
-
};
|
|
1850
|
+
console.error('[CONTEXT] Failed to load widgets:', error.message);
|
|
1851
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
1826
1852
|
}
|
|
1827
1853
|
} else {
|
|
1828
|
-
|
|
1829
|
-
console.warn('[CONTEXT] This may happen if widgetService was not initialized properly');
|
|
1830
|
-
context.widgets = {
|
|
1831
|
-
hero: [],
|
|
1832
|
-
products: [],
|
|
1833
|
-
footer: [],
|
|
1834
|
-
content: [],
|
|
1835
|
-
header: []
|
|
1836
|
-
};
|
|
1854
|
+
context.widgets = { header: [], hero: [], content: [], footer: [] };
|
|
1837
1855
|
}
|
|
1838
1856
|
|
|
1839
1857
|
// Always load products, categories, brands, menus, and cart for navigation and widgets
|
|
@@ -1860,32 +1878,17 @@ class DevServer {
|
|
|
1860
1878
|
stockQuantity: product.stockQuantity !== undefined ? product.stockQuantity : 'MISSING',
|
|
1861
1879
|
inStock: product.inStock !== undefined ? product.inStock : 'MISSING',
|
|
1862
1880
|
available: product.available !== undefined ? product.available : 'MISSING',
|
|
1863
|
-
hasImage: !!(product.imageUrl || product.
|
|
1864
|
-
imageUrl: product.imageUrl || product.
|
|
1881
|
+
hasImage: !!(product.imageUrl || product.thumbnailImage),
|
|
1882
|
+
imageUrl: product.imageUrl || product.thumbnailImage?.url || 'MISSING'
|
|
1865
1883
|
});
|
|
1866
1884
|
});
|
|
1867
1885
|
}
|
|
1868
1886
|
|
|
1869
1887
|
console.log(`[CONTEXT] Loaded ${context.products.length} products`);
|
|
1870
1888
|
|
|
1871
|
-
// DEBUG: Verify products have quantity/stock field
|
|
1872
1889
|
if (context.products.length > 0) {
|
|
1873
|
-
const
|
|
1874
|
-
|
|
1875
|
-
// Use nullish coalescing to show 0 values correctly (0 || 'N/A' would show N/A incorrectly)
|
|
1876
|
-
const stockValue = firstProduct.stock ?? firstProduct.quantity ?? 'N/A';
|
|
1877
|
-
console.log(`[CONTEXT DEBUG] First product sample - Has stock field: ${hasStock}, Stock value: ${stockValue}`);
|
|
1878
|
-
console.log(`[CONTEXT DEBUG] First product keys: ${Object.keys(firstProduct).join(', ')}`);
|
|
1879
|
-
console.log(`[CONTEXT DEBUG] First product stock details: stock=${firstProduct.stock}, quantity=${firstProduct.quantity}, inStock=${firstProduct.inStock}`);
|
|
1880
|
-
|
|
1881
|
-
// Check if variants have stock
|
|
1882
|
-
if (firstProduct.variants && firstProduct.variants.length > 0) {
|
|
1883
|
-
const firstVariant = firstProduct.variants[0];
|
|
1884
|
-
const variantHasStock = 'stock' in firstVariant || 'quantity' in firstVariant;
|
|
1885
|
-
const variantStockValue = firstVariant.stock ?? firstVariant.quantity ?? 'N/A';
|
|
1886
|
-
console.log(`[CONTEXT DEBUG] First variant sample - Has stock field: ${variantHasStock}, Stock value: ${variantStockValue}`);
|
|
1887
|
-
console.log(`[CONTEXT DEBUG] First variant stock details: stock=${firstVariant.stock}, quantity=${firstVariant.quantity}, inStock=${firstVariant.inStock}, available=${firstVariant.available}`);
|
|
1888
|
-
}
|
|
1890
|
+
const fp = context.products[0];
|
|
1891
|
+
console.log(`[CONTEXT] First product: id=${fp.id}, slug=${fp.slug}, stockQuantity=${fp.stockQuantity}, inStock=${fp.inStock}`);
|
|
1889
1892
|
}
|
|
1890
1893
|
|
|
1891
1894
|
// Load categories for navigation
|
|
@@ -1893,13 +1896,12 @@ class DevServer {
|
|
|
1893
1896
|
const categoriesResponse = await this.apiClient.getCategories({ limit: 50, offset: 0 });
|
|
1894
1897
|
context.categories = categoriesResponse.categories || categoriesResponse.data?.categories || categoriesResponse || [];
|
|
1895
1898
|
|
|
1896
|
-
// Ensure all categories have proper URL fields for navigation
|
|
1897
1899
|
context.categories = context.categories.map(category => {
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
category.
|
|
1900
|
+
if (!category.slug) {
|
|
1901
|
+
category.slug = category.handle || category.name?.toLowerCase().replace(/\s+/g, '-') || String(category.id);
|
|
1902
|
+
}
|
|
1903
|
+
if (!category.handle) {
|
|
1904
|
+
category.handle = category.slug;
|
|
1903
1905
|
}
|
|
1904
1906
|
return category;
|
|
1905
1907
|
});
|
|
@@ -1922,30 +1924,40 @@ class DevServer {
|
|
|
1922
1924
|
context.brands = [];
|
|
1923
1925
|
}
|
|
1924
1926
|
|
|
1925
|
-
// Load menus (via webstoreapi) - use axios from apiClient
|
|
1926
|
-
// NOTE: Menus are loaded for navigation in header/footer sections
|
|
1927
|
-
// They should be available in context for all page types
|
|
1928
1927
|
try {
|
|
1929
1928
|
const axios = require('axios');
|
|
1930
|
-
console.log(`[CONTEXT] Loading menus from http://localhost:${this.mockApiPort}/webstoreapi/menus`);
|
|
1931
1929
|
const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
|
|
1932
|
-
const
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1930
|
+
const rawMenus = menusResponse.data;
|
|
1931
|
+
const allMenus = Array.isArray(rawMenus) ? rawMenus : (rawMenus.data?.menus || rawMenus.menus || []);
|
|
1932
|
+
|
|
1933
|
+
const menusData = {};
|
|
1934
|
+
allMenus.forEach(menu => {
|
|
1935
|
+
if (menu && menu.name) {
|
|
1936
|
+
const normalizedName = menu.name.toLowerCase()
|
|
1937
|
+
.replace(/\s+menu$/i, '')
|
|
1938
|
+
.replace(/\s+/g, '_')
|
|
1939
|
+
.replace(/[^a-z0-9_]/g, '');
|
|
1940
|
+
menusData[normalizedName] = menu;
|
|
1941
|
+
}
|
|
1942
|
+
});
|
|
1943
|
+
menusData.all = allMenus;
|
|
1944
|
+
context.menus = menusData;
|
|
1945
|
+
|
|
1946
|
+
const mainMenuData = allMenus.find(menu => menu && menu.type === 'Main Menu');
|
|
1947
|
+
if (mainMenuData) {
|
|
1948
|
+
if (mainMenuData.items && Array.isArray(mainMenuData.items)) {
|
|
1949
|
+
mainMenuData.items.sort((a, b) => (a?.displayOrder || 0) - (b?.displayOrder || 0));
|
|
1950
|
+
}
|
|
1951
|
+
context.mainMenu = mainMenuData;
|
|
1952
|
+
} else if (allMenus.length > 0) {
|
|
1953
|
+
context.mainMenu = allMenus[0];
|
|
1944
1954
|
}
|
|
1955
|
+
|
|
1956
|
+
console.log(`[CONTEXT] Loaded ${allMenus.length} menus`);
|
|
1945
1957
|
} catch (error) {
|
|
1946
|
-
console.error('[CONTEXT]
|
|
1947
|
-
|
|
1948
|
-
context.
|
|
1958
|
+
console.error('[CONTEXT] Failed to load menus:', error.message);
|
|
1959
|
+
context.menus = {};
|
|
1960
|
+
context.mainMenu = null;
|
|
1949
1961
|
}
|
|
1950
1962
|
|
|
1951
1963
|
// Load cart data
|
|
@@ -2029,73 +2041,212 @@ class DevServer {
|
|
|
2029
2041
|
}
|
|
2030
2042
|
} catch (error) {
|
|
2031
2043
|
console.warn('[CONTEXT] Failed to load from mock API:', error.message);
|
|
2032
|
-
// Fallback to default values
|
|
2033
2044
|
context.shop = {
|
|
2034
2045
|
name: 'My Store',
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2046
|
+
domain: hostname,
|
|
2047
|
+
currency: 'USD',
|
|
2048
|
+
timezone: 'America/New_York',
|
|
2049
|
+
language: 'en',
|
|
2050
|
+
logo: '/assets/logo.png',
|
|
2051
|
+
favicon: '/favicon.ico',
|
|
2052
|
+
id: 1,
|
|
2053
|
+
identifier: 'store',
|
|
2054
|
+
companyName: 'My Store',
|
|
2055
|
+
companyAddress: '',
|
|
2056
|
+
companyPhoneNumber: '',
|
|
2057
|
+
companyEmail: '',
|
|
2058
|
+
settings: {}
|
|
2038
2059
|
};
|
|
2039
2060
|
context.widgets = {
|
|
2040
2061
|
hero: [],
|
|
2041
|
-
|
|
2062
|
+
content: [],
|
|
2042
2063
|
footer: [],
|
|
2043
|
-
|
|
2064
|
+
header: []
|
|
2044
2065
|
};
|
|
2045
2066
|
}
|
|
2046
|
-
} else if (this.
|
|
2047
|
-
//
|
|
2067
|
+
} else if (this.mode === 'real') {
|
|
2068
|
+
// Real mode: use webstoreapi-fetcher only (no apiClient). Thin HTTP calls to /webstoreapi/*.
|
|
2069
|
+
const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
|
|
2070
|
+
const baseHost = (process.env.O2VEND_API_BASE_URL || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '') || 'localhost';
|
|
2071
|
+
const themeSettings = context.settings || {};
|
|
2072
|
+
let storeInfo = null;
|
|
2048
2073
|
try {
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2074
|
+
storeInfo = await webstoreApi.fetchStoreInfo();
|
|
2075
|
+
} catch (e) {
|
|
2076
|
+
/* use fallbacks */
|
|
2077
|
+
}
|
|
2078
|
+
const logo = (storeInfo && storeInfo.logoUrl) ? storeInfo.logoUrl : '/assets/logo.png';
|
|
2079
|
+
const favicon = (storeInfo && storeInfo.favouriteIconUrl) ? storeInfo.favouriteIconUrl : '/favicon.ico';
|
|
2080
|
+
const storeName = (storeInfo && storeInfo.name) ? storeInfo.name : 'Store';
|
|
2081
|
+
const storeSettings = (storeInfo && storeInfo.settings) || {};
|
|
2082
|
+
try {
|
|
2083
|
+
context.shop = {
|
|
2084
|
+
name: storeName,
|
|
2085
|
+
domain: baseHost,
|
|
2086
|
+
currency: storeSettings.currency || themeSettings.currency || 'USD',
|
|
2087
|
+
currencySymbol: storeSettings.currencySymbol || '$',
|
|
2088
|
+
timezone: storeSettings.timezone || 'America/New_York',
|
|
2089
|
+
language: storeSettings.language || themeSettings.language || 'en',
|
|
2090
|
+
logo,
|
|
2091
|
+
favicon,
|
|
2092
|
+
id: storeInfo?.id || process.env.O2VEND_TENANT_ID || 1,
|
|
2093
|
+
identifier: storeInfo?.identifier || 'store',
|
|
2094
|
+
companyName: storeSettings.companyName || storeName,
|
|
2095
|
+
companyAddress: storeSettings.companyAddress || '',
|
|
2096
|
+
companyPhoneNumber: storeSettings.companyPhoneNumber || '',
|
|
2097
|
+
companyEmail: storeSettings.companyEmail || '',
|
|
2098
|
+
settings: storeSettings
|
|
2099
|
+
};
|
|
2100
|
+
context.store = {
|
|
2101
|
+
id: context.shop.id,
|
|
2102
|
+
identifier: context.shop.identifier,
|
|
2103
|
+
name: context.shop.name,
|
|
2104
|
+
settings: storeSettings,
|
|
2105
|
+
companyName: context.shop.companyName,
|
|
2106
|
+
companyAddress: context.shop.companyAddress,
|
|
2107
|
+
companyPhoneNumber: context.shop.companyPhoneNumber,
|
|
2108
|
+
companyEmail: context.shop.companyEmail
|
|
2109
|
+
};
|
|
2110
|
+
context.tenant = {
|
|
2111
|
+
id: process.env.O2VEND_TENANT_ID || 1,
|
|
2112
|
+
name: storeName,
|
|
2113
|
+
settings: storeSettings
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
if (process.env.O2VEND_LAYOUT_FULL === '1' || process.env.O2VEND_LAYOUT_FULL === 'true') {
|
|
2117
|
+
context.settings = context.settings || {};
|
|
2118
|
+
context.settings.layout_style = 'full-width';
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
const page = pageType === 'home' ? 'home' : (pageType === 'products' ? 'products' : 'home');
|
|
2122
|
+
context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
|
|
2123
|
+
context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
|
|
2124
|
+
|
|
2125
|
+
if (this.widgetService) {
|
|
2126
|
+
Object.keys(context.widgets).forEach((section) => {
|
|
2127
|
+
if (Array.isArray(context.widgets[section])) {
|
|
2128
|
+
context.widgets[section].forEach((widget) => {
|
|
2129
|
+
if (!widget.template_path && widget.template) widget.template_path = `widgets/${widget.template}`;
|
|
2130
|
+
if (!widget.template_path && widget.type) {
|
|
2131
|
+
const slug = this.widgetService.getTemplateSlug(widget.type);
|
|
2132
|
+
widget.template_path = `widgets/${slug}`;
|
|
2133
|
+
widget.template = slug;
|
|
2134
|
+
}
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
});
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const productsRes = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, opts);
|
|
2141
|
+
context.products = productsRes?.products || productsRes?.Products || [];
|
|
2142
|
+
context.products = this.normalizeProductsForRealMode(context.products);
|
|
2143
|
+
|
|
2144
|
+
try {
|
|
2145
|
+
const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
|
|
2146
|
+
const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
|
|
2147
|
+
context.categories = raw.map((c) => {
|
|
2148
|
+
const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().trim() || null;
|
|
2149
|
+
if (h) {
|
|
2150
|
+
c.handle = c.handle || h;
|
|
2151
|
+
c.slug = c.slug || h;
|
|
2152
|
+
}
|
|
2153
|
+
return c;
|
|
2154
|
+
});
|
|
2155
|
+
context.collections = context.categories;
|
|
2156
|
+
} catch (e) {
|
|
2157
|
+
context.categories = [];
|
|
2158
|
+
context.collections = [];
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
try {
|
|
2162
|
+
const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
|
|
2163
|
+
context.brands = brandRes?.brands || brandRes?.data?.brands || brandRes || [];
|
|
2164
|
+
} catch (e) {
|
|
2165
|
+
context.brands = [];
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
try {
|
|
2169
|
+
const allMenus = await webstoreApi.fetchMenus(opts);
|
|
2170
|
+
const menusList = Array.isArray(allMenus) ? allMenus : [];
|
|
2171
|
+
const menusData = {};
|
|
2172
|
+
menusList.forEach(menu => {
|
|
2173
|
+
if (menu && menu.name) {
|
|
2174
|
+
const normalizedName = menu.name.toLowerCase()
|
|
2175
|
+
.replace(/\s+menu$/i, '')
|
|
2176
|
+
.replace(/\s+/g, '_')
|
|
2177
|
+
.replace(/[^a-z0-9_]/g, '');
|
|
2178
|
+
menusData[normalizedName] = menu;
|
|
2179
|
+
}
|
|
2180
|
+
});
|
|
2181
|
+
menusData.all = menusList;
|
|
2182
|
+
context.menus = menusData;
|
|
2183
|
+
|
|
2184
|
+
const mainMenuData = menusList.find(m => m && m.type === 'Main Menu');
|
|
2185
|
+
const candidate = mainMenuData || menusList[0];
|
|
2186
|
+
if (candidate && candidate.id) {
|
|
2187
|
+
try {
|
|
2188
|
+
const fullMenu = await webstoreApi.fetchMenuById(candidate.id, opts);
|
|
2189
|
+
if (fullMenu && fullMenu.items && Array.isArray(fullMenu.items)) {
|
|
2190
|
+
fullMenu.items.sort((a, b) => (a?.displayOrder || 0) - (b?.displayOrder || 0));
|
|
2068
2191
|
}
|
|
2069
|
-
|
|
2192
|
+
context.mainMenu = fullMenu || candidate;
|
|
2193
|
+
} catch (e) {
|
|
2194
|
+
context.mainMenu = candidate;
|
|
2195
|
+
}
|
|
2070
2196
|
}
|
|
2071
|
-
})
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
if (
|
|
2080
|
-
context.
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
context.menus = {};
|
|
2199
|
+
context.mainMenu = null;
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
try {
|
|
2203
|
+
const cartRes = await webstoreApi.fetchCart(opts);
|
|
2204
|
+
const data = cartRes?.data || cartRes;
|
|
2205
|
+
if (data) {
|
|
2206
|
+
context.cart = {
|
|
2207
|
+
...context.cart,
|
|
2208
|
+
...data,
|
|
2209
|
+
itemCount: data.items?.length ?? data.itemCount ?? 0,
|
|
2210
|
+
total: data.total ?? data.subTotal ?? 0
|
|
2211
|
+
};
|
|
2085
2212
|
}
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2213
|
+
} catch (e) {
|
|
2214
|
+
/* cart optional */
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (pageType === 'products') {
|
|
2218
|
+
context.collection = context.collection || {};
|
|
2219
|
+
context.collection.products = context.products;
|
|
2220
|
+
context.collection.title = 'All Products';
|
|
2221
|
+
context.collection.handle = 'all';
|
|
2222
|
+
context.collection.totalProducts = context.products.length;
|
|
2089
2223
|
} 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
2224
|
context.collection = context.collection || {};
|
|
2095
2225
|
context.collection.products = context.products;
|
|
2226
|
+
} else if (pageType === 'product' && extra.productHandle) {
|
|
2227
|
+
const handle = String(extra.productHandle).toLowerCase().trim();
|
|
2228
|
+
let product = context.products.find((p) => {
|
|
2229
|
+
const h = (p.handle || '').toLowerCase().trim();
|
|
2230
|
+
const s = (p.slug || '').toLowerCase().trim();
|
|
2231
|
+
const id = String(p.id || '').toLowerCase().trim();
|
|
2232
|
+
const pid = String(p.productId || '').toLowerCase().trim();
|
|
2233
|
+
if (h === handle || s === handle || id === handle) return true;
|
|
2234
|
+
if (handle.startsWith('product-') && (pid === handle.replace(/^product-/, '') || id === handle.replace(/^product-/, ''))) return true;
|
|
2235
|
+
return false;
|
|
2236
|
+
});
|
|
2237
|
+
if (!product) {
|
|
2238
|
+
try {
|
|
2239
|
+
const idToFetch = handle.startsWith('product-') ? handle.replace(/^product-/, '') : handle;
|
|
2240
|
+
product = await webstoreApi.fetchProductById(idToFetch, opts);
|
|
2241
|
+
if (product) product = this.normalizeProductForRealMode(product);
|
|
2242
|
+
} catch (e) {
|
|
2243
|
+
/* ignore */
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
if (product) context.product = product;
|
|
2096
2247
|
}
|
|
2097
2248
|
} catch (error) {
|
|
2098
|
-
console.warn('[CONTEXT]
|
|
2249
|
+
console.warn('[CONTEXT] Real mode fetch failed:', error.message);
|
|
2099
2250
|
}
|
|
2100
2251
|
}
|
|
2101
2252
|
|
|
@@ -2133,36 +2284,18 @@ class DevServer {
|
|
|
2133
2284
|
// Widget templates use: widget.data.products, widget_data.products
|
|
2134
2285
|
if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
|
|
2135
2286
|
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
|
|
2287
|
+
const formatted = this.mode === 'real'
|
|
2288
|
+
? this.normalizeProductsForRealMode(widgetProducts)
|
|
2289
|
+
: this.enrichProductsData(widgetProducts);
|
|
2290
|
+
|
|
2291
|
+
widget.data.products = formatted;
|
|
2292
|
+
widget.data.Products = formatted;
|
|
2293
|
+
widget.products = formatted;
|
|
2146
2294
|
widget.data.content = widget.data.content || {};
|
|
2147
|
-
widget.data.content.products =
|
|
2148
|
-
widget.data.content.Products =
|
|
2149
|
-
|
|
2295
|
+
widget.data.content.products = formatted;
|
|
2296
|
+
widget.data.content.Products = formatted;
|
|
2150
2297
|
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
|
-
}
|
|
2298
|
+
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${formatted.length} products`);
|
|
2166
2299
|
}
|
|
2167
2300
|
|
|
2168
2301
|
// Enrich CategoryList and CategoryListCarousel widgets with categories
|
|
@@ -2199,18 +2332,18 @@ class DevServer {
|
|
|
2199
2332
|
|
|
2200
2333
|
// Enrich RecentlyViewed widgets with products (use first 6 products as mock recent views)
|
|
2201
2334
|
if (type === 'recentlyviewed' || type === 'recently-viewed') {
|
|
2202
|
-
const
|
|
2335
|
+
const recent = products.slice(0, 6);
|
|
2336
|
+
const recentProducts = this.mode === 'real'
|
|
2337
|
+
? this.normalizeProductsForRealMode(recent)
|
|
2338
|
+
: this.enrichProductsData(recent);
|
|
2203
2339
|
widget.data.products = recentProducts;
|
|
2204
2340
|
widget.data.Products = recentProducts;
|
|
2205
2341
|
widget.products = recentProducts;
|
|
2206
|
-
|
|
2207
|
-
// Also set in content
|
|
2208
2342
|
widget.data.content = widget.data.content || {};
|
|
2209
2343
|
widget.data.content.products = recentProducts;
|
|
2210
2344
|
widget.data.content.Products = recentProducts;
|
|
2211
|
-
|
|
2212
2345
|
enrichedCount++;
|
|
2213
|
-
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length}
|
|
2346
|
+
console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
|
|
2214
2347
|
}
|
|
2215
2348
|
});
|
|
2216
2349
|
});
|
|
@@ -2218,6 +2351,28 @@ class DevServer {
|
|
|
2218
2351
|
console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
|
|
2219
2352
|
}
|
|
2220
2353
|
|
|
2354
|
+
/**
|
|
2355
|
+
* Check if a product belongs to a category (for collection/category filtering).
|
|
2356
|
+
* Handles categoryId, primaryCategoryId, categoryHandle, and categories[].
|
|
2357
|
+
* @param {Object} p - Product
|
|
2358
|
+
* @param {Object} category - Category { id, handle }
|
|
2359
|
+
* @returns {boolean}
|
|
2360
|
+
*/
|
|
2361
|
+
productMatchesCategory(p, category) {
|
|
2362
|
+
const cid = category.id;
|
|
2363
|
+
const ch = (category.handle || category.slug || '').toLowerCase();
|
|
2364
|
+
if (p.categoryId != null && (p.categoryId === cid || String(p.categoryId).toLowerCase() === String(cid).toLowerCase())) return true;
|
|
2365
|
+
if (p.primaryCategoryId != null && (p.primaryCategoryId === cid || String(p.primaryCategoryId).toLowerCase() === String(cid).toLowerCase())) return true;
|
|
2366
|
+
const ph = (p.categoryHandle || p.category?.handle || '').toLowerCase();
|
|
2367
|
+
if (ch && ph && ph === ch) return true;
|
|
2368
|
+
const arr = p.categories || p.categoryIds || [];
|
|
2369
|
+
if (Array.isArray(arr) && arr.some(cat => {
|
|
2370
|
+
const o = typeof cat === 'object' ? cat : { id: cat };
|
|
2371
|
+
return o.id === cid || (o.handle || o.slug || '').toLowerCase() === ch;
|
|
2372
|
+
})) return true;
|
|
2373
|
+
return false;
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2221
2376
|
/**
|
|
2222
2377
|
* Enrich a single product with all required data (URL, name/title, price, images, stock)
|
|
2223
2378
|
* This ensures consistency between widgets and pages
|
|
@@ -2225,217 +2380,138 @@ class DevServer {
|
|
|
2225
2380
|
* @returns {Object} Enriched product (creates a copy to avoid mutation)
|
|
2226
2381
|
*/
|
|
2227
2382
|
enrichProductData(product) {
|
|
2228
|
-
// Create a copy to avoid mutating the original
|
|
2229
2383
|
const enriched = { ...product };
|
|
2230
|
-
|
|
2231
|
-
// CRITICAL: Ensure productId exists - it's the most reliable identifier
|
|
2384
|
+
|
|
2232
2385
|
if (!enriched.productId && enriched.id) {
|
|
2233
|
-
// Try to extract numeric ID from string ID (e.g., "product-1" -> 1)
|
|
2234
2386
|
const numericMatch = String(enriched.id).match(/\d+/);
|
|
2235
|
-
|
|
2236
|
-
enriched.productId = parseInt(numericMatch[0], 10);
|
|
2237
|
-
} else {
|
|
2238
|
-
// Use the ID as-is if it's already numeric
|
|
2239
|
-
enriched.productId = enriched.id;
|
|
2240
|
-
}
|
|
2241
|
-
} else if (!enriched.productId && !enriched.id) {
|
|
2242
|
-
console.warn(`[ENRICH] ⚠️ Product missing both id and productId:`, {
|
|
2243
|
-
keys: Object.keys(enriched).slice(0, 15),
|
|
2244
|
-
title: enriched.title || enriched.name
|
|
2245
|
-
});
|
|
2246
|
-
}
|
|
2247
|
-
|
|
2248
|
-
// DEBUG: Log incoming product to diagnose missing fields
|
|
2249
|
-
if (!product.id && !product.handle && !product.slug && !product.productId) {
|
|
2250
|
-
console.warn(`[ENRICH] ⚠️ Product missing all identifiers (id/handle/slug/productId):`, {
|
|
2251
|
-
keys: Object.keys(product).slice(0, 15),
|
|
2252
|
-
title: product.title || product.name
|
|
2253
|
-
});
|
|
2387
|
+
enriched.productId = numericMatch ? parseInt(numericMatch[0], 10) : enriched.id;
|
|
2254
2388
|
}
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
if (handle
|
|
2264
|
-
|
|
2265
|
-
enriched.link = `/products/${handle}`;
|
|
2266
|
-
} else {
|
|
2267
|
-
// Last resort: use numeric ID if available
|
|
2268
|
-
const numericId = enriched.productId || enriched.id;
|
|
2269
|
-
if (numericId) {
|
|
2270
|
-
const fallbackHandle = `product-${numericId}`.toLowerCase();
|
|
2271
|
-
enriched.url = `/products/${fallbackHandle}`;
|
|
2272
|
-
enriched.link = `/products/${fallbackHandle}`;
|
|
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;
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
// Ensure handle/slug exist if missing (for template fallback logic)
|
|
2294
|
-
// Also normalize them (remove '#' or empty strings)
|
|
2295
|
-
if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') {
|
|
2296
|
-
if (enriched.slug && enriched.slug !== '#' && enriched.slug !== '') {
|
|
2297
|
-
enriched.handle = enriched.slug;
|
|
2298
|
-
} else if (enriched.id && enriched.id !== '#') {
|
|
2299
|
-
enriched.handle = String(enriched.id).toLowerCase();
|
|
2300
|
-
} else if (enriched.productId) {
|
|
2301
|
-
enriched.handle = `product-${enriched.productId}`.toLowerCase();
|
|
2302
|
-
} else if (!enriched.handle) {
|
|
2303
|
-
// Generate from URL if it was set above
|
|
2304
|
-
if (enriched.url && enriched.url !== '#') {
|
|
2305
|
-
enriched.handle = enriched.url.replace(/^\/products\//, '').toLowerCase();
|
|
2306
|
-
}
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
|
|
2310
|
-
if (!enriched.slug || enriched.slug === '#' || enriched.slug === '') {
|
|
2311
|
-
if (enriched.handle && enriched.handle !== '#' && enriched.handle !== '') {
|
|
2312
|
-
enriched.slug = enriched.handle;
|
|
2313
|
-
} else if (enriched.id && enriched.id !== '#') {
|
|
2314
|
-
enriched.slug = String(enriched.id).toLowerCase();
|
|
2315
|
-
} else if (enriched.productId) {
|
|
2316
|
-
enriched.slug = `product-${enriched.productId}`.toLowerCase();
|
|
2317
|
-
} else if (!enriched.slug) {
|
|
2318
|
-
// Generate from URL if it was set above
|
|
2319
|
-
if (enriched.url && enriched.url !== '#') {
|
|
2320
|
-
enriched.slug = enriched.url.replace(/^\/products\//, '').toLowerCase();
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2389
|
+
|
|
2390
|
+
const handle = enriched.handle || enriched.slug || String(enriched.id || enriched.productId || '');
|
|
2391
|
+
const effectiveHandle = (handle && String(handle).trim() && String(handle) !== '#')
|
|
2392
|
+
? String(handle).trim().toLowerCase() : null;
|
|
2393
|
+
|
|
2394
|
+
if (effectiveHandle) {
|
|
2395
|
+
enriched.url = effectiveHandle;
|
|
2396
|
+
enriched.link = effectiveHandle;
|
|
2397
|
+
if (!enriched.handle) enriched.handle = effectiveHandle;
|
|
2398
|
+
if (!enriched.slug) enriched.slug = effectiveHandle;
|
|
2323
2399
|
}
|
|
2324
|
-
|
|
2325
|
-
// Ensure name/title exists - CRITICAL for product display
|
|
2400
|
+
|
|
2326
2401
|
if (!enriched.name && !enriched.title) {
|
|
2327
2402
|
enriched.name = `Product ${enriched.id || 'Unknown'}`;
|
|
2328
2403
|
enriched.title = enriched.name;
|
|
2329
2404
|
} else if (!enriched.name) {
|
|
2330
|
-
enriched.name = enriched.title
|
|
2405
|
+
enriched.name = enriched.title;
|
|
2331
2406
|
} else if (!enriched.title) {
|
|
2332
2407
|
enriched.title = enriched.name;
|
|
2333
2408
|
}
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
if (enriched.
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
enriched.variants
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
// Ensure price exists - CRITICAL for product display
|
|
2343
|
-
if (!enriched.price && !enriched.sellingPrice) {
|
|
2344
|
-
// Try to get from variants/variations first
|
|
2345
|
-
const variantsOrVariations = enriched.variants || enriched.variations;
|
|
2346
|
-
if (variantsOrVariations && variantsOrVariations.length > 0) {
|
|
2347
|
-
enriched.price = variantsOrVariations[0].price || variantsOrVariations[0].sellingPrice || 0;
|
|
2348
|
-
enriched.sellingPrice = enriched.price;
|
|
2349
|
-
} else {
|
|
2350
|
-
enriched.price = 0;
|
|
2351
|
-
enriched.sellingPrice = 0;
|
|
2352
|
-
}
|
|
2353
|
-
} else if (!enriched.price) {
|
|
2354
|
-
enriched.price = enriched.sellingPrice;
|
|
2355
|
-
} else if (!enriched.sellingPrice) {
|
|
2356
|
-
enriched.sellingPrice = enriched.price;
|
|
2409
|
+
|
|
2410
|
+
if (enriched.variants && !enriched.variations) enriched.variations = enriched.variants;
|
|
2411
|
+
else if (enriched.variations && !enriched.variants) enriched.variants = enriched.variations;
|
|
2412
|
+
|
|
2413
|
+
if (enriched.price == null && enriched.sellingPrice == null) {
|
|
2414
|
+
const v = enriched.variants || enriched.variations;
|
|
2415
|
+
enriched.price = (v && v.length > 0) ? (v[0].price || 0) : 0;
|
|
2357
2416
|
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
// Make sure price is NOT 0 unless it's actually 0 (check if undefined/null)
|
|
2361
|
-
const finalPrice = (enriched.price !== undefined && enriched.price !== null)
|
|
2362
|
-
? enriched.price
|
|
2363
|
-
: ((enriched.sellingPrice !== undefined && enriched.sellingPrice !== null)
|
|
2364
|
-
? enriched.sellingPrice
|
|
2365
|
-
: 0);
|
|
2366
|
-
|
|
2417
|
+
if (enriched.price == null) enriched.price = enriched.sellingPrice || 0;
|
|
2418
|
+
|
|
2367
2419
|
if (!enriched.prices) {
|
|
2368
|
-
const mrpValue = enriched.compareAtPrice || enriched.comparePrice || enriched.mrp || 0;
|
|
2369
2420
|
enriched.prices = {
|
|
2370
|
-
price:
|
|
2371
|
-
mrp:
|
|
2372
|
-
currency: enriched.currency || 'USD'
|
|
2421
|
+
price: enriched.price,
|
|
2422
|
+
mrp: enriched.compareAtPrice || enriched.comparePrice || 0
|
|
2373
2423
|
};
|
|
2374
|
-
} else {
|
|
2375
|
-
// Update prices object if it exists but is missing fields
|
|
2376
|
-
if (enriched.prices.price === undefined || enriched.prices.price === null) {
|
|
2377
|
-
enriched.prices.price = finalPrice;
|
|
2378
|
-
}
|
|
2379
|
-
if (enriched.prices.mrp === undefined || enriched.prices.mrp === null) {
|
|
2380
|
-
enriched.prices.mrp = enriched.compareAtPrice || enriched.comparePrice || enriched.mrp || 0;
|
|
2381
|
-
}
|
|
2382
|
-
if (!enriched.prices.currency) {
|
|
2383
|
-
enriched.prices.currency = enriched.currency || 'USD';
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
// Ensure price/sellingPrice fields also exist (not just prices object)
|
|
2388
|
-
if (enriched.price === undefined || enriched.price === null) {
|
|
2389
|
-
enriched.price = finalPrice;
|
|
2390
2424
|
}
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
}
|
|
2394
|
-
|
|
2395
|
-
// Ensure image exists - CRITICAL for product display
|
|
2396
|
-
if (!enriched.imageUrl && !enriched.thumbnailImage1 && (!enriched.images || enriched.images.length === 0)) {
|
|
2397
|
-
// Fallback to picsum placeholder
|
|
2425
|
+
|
|
2426
|
+
if (!enriched.imageUrl && !enriched.thumbnailImage && (!enriched.images || enriched.images.length === 0)) {
|
|
2398
2427
|
const imageId = enriched.id ? String(enriched.id).replace(/\D/g, '') || '1' : '1';
|
|
2399
2428
|
enriched.imageUrl = `https://picsum.photos/seed/${imageId}/800/800`;
|
|
2400
|
-
enriched.
|
|
2401
|
-
url: enriched.imageUrl,
|
|
2402
|
-
altText: enriched.title || enriched.name || 'Product image'
|
|
2403
|
-
};
|
|
2429
|
+
enriched.thumbnailImage = enriched.imageUrl;
|
|
2404
2430
|
}
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2431
|
+
|
|
2432
|
+
this.enrichProductStockStatus(enriched);
|
|
2433
|
+
|
|
2434
|
+
return enriched;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
/**
|
|
2438
|
+
* Compute inStock and available flags matching production enrichProductStockStatus().
|
|
2439
|
+
*/
|
|
2440
|
+
enrichProductStockStatus(product) {
|
|
2441
|
+
const stockQuantity = product.stockQuantity || 0;
|
|
2442
|
+
const stockTrackingIsEnabled = product.stockTrackingIsEnabled || false;
|
|
2443
|
+
const isAllowToOrder = product.isAllowToOrder || false;
|
|
2444
|
+
|
|
2445
|
+
product.inStock = !stockTrackingIsEnabled || stockQuantity > 0;
|
|
2446
|
+
product.available = !stockTrackingIsEnabled || stockQuantity > 0 || isAllowToOrder;
|
|
2447
|
+
|
|
2448
|
+
if (product.stockQuantity == null) {
|
|
2449
|
+
const variants = product.variants || product.variations;
|
|
2450
|
+
if (variants && variants.length > 0) {
|
|
2451
|
+
product.stockQuantity = variants.reduce((sum, v) => sum + (v.stockQuantity || 0), 0);
|
|
2452
|
+
product.inStock = !stockTrackingIsEnabled || product.stockQuantity > 0;
|
|
2453
|
+
product.available = !stockTrackingIsEnabled || product.stockQuantity > 0 || isAllowToOrder;
|
|
2422
2454
|
}
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
return product;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
/**
|
|
2461
|
+
* Normalize real-API product for template use (no enrichment).
|
|
2462
|
+
* Aliases API keys (Description, Images, etc.) to template-expected keys,
|
|
2463
|
+
* sets url/link for dev navigation, and normalizes image items to { url, altText }.
|
|
2464
|
+
* @param {Object} p - Product from real API
|
|
2465
|
+
* @returns {Object} Normalized product (shallow copy + aliases)
|
|
2466
|
+
*/
|
|
2467
|
+
normalizeProductForRealMode(p) {
|
|
2468
|
+
if (!p || typeof p !== 'object') return p;
|
|
2469
|
+
const n = { ...p };
|
|
2470
|
+
if (n.Description != null && n.description == null) n.description = n.Description;
|
|
2471
|
+
if (n.HtmlContent != null && n.htmlContent == null) n.htmlContent = n.HtmlContent;
|
|
2472
|
+
if (n.ShortDescription != null && n.shortDescription == null) n.shortDescription = n.ShortDescription;
|
|
2473
|
+
if (n.Name != null && n.name == null) n.name = n.Name;
|
|
2474
|
+
if (n.Title != null && n.title == null) n.title = n.Title;
|
|
2475
|
+
if (n.Attributes != null && n.attributes == null) n.attributes = n.Attributes;
|
|
2476
|
+
|
|
2477
|
+
const t1 = n.ThumbnailImage1 || n.thumbnailImage || n.thumbnailImage;
|
|
2478
|
+
if (t1 != null) {
|
|
2479
|
+
const u = typeof t1 === 'string' ? t1 : (t1.url || t1.Url || t1.imageUrl || t1.ImageUrl);
|
|
2480
|
+
if (u) {
|
|
2481
|
+
n.thumbnailImage = u;
|
|
2482
|
+
if (!n.imageUrl) n.imageUrl = u;
|
|
2435
2483
|
}
|
|
2436
2484
|
}
|
|
2437
|
-
|
|
2438
|
-
|
|
2485
|
+
|
|
2486
|
+
let imgs = n.images || n.Images;
|
|
2487
|
+
if (Array.isArray(imgs) && imgs.length > 0) {
|
|
2488
|
+
n.images = imgs.map((img) => {
|
|
2489
|
+
if (typeof img === 'string') return { url: img, altText: n.name || n.title };
|
|
2490
|
+
const u = img.url || img.Url || img.imageUrl || img.ImageUrl;
|
|
2491
|
+
const a = img.altText || img.AltText || n.name || n.title;
|
|
2492
|
+
return { url: u || '', altText: a };
|
|
2493
|
+
});
|
|
2494
|
+
} else {
|
|
2495
|
+
const single = n.imageUrl || n.ImageUrl;
|
|
2496
|
+
if (single) n.images = [{ url: single, altText: n.name || n.title }];
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
const handle = (n.handle || n.slug || n.id || '').toString().trim();
|
|
2500
|
+
const h = handle && handle !== '#' ? handle.toLowerCase() : (n.productId ? String(n.productId) : null);
|
|
2501
|
+
if (h) {
|
|
2502
|
+
n.url = h;
|
|
2503
|
+
n.link = h;
|
|
2504
|
+
if (!n.handle) n.handle = h;
|
|
2505
|
+
if (!n.slug) n.slug = h;
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
this.enrichProductStockStatus(n);
|
|
2509
|
+
return n;
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
normalizeProductsForRealMode(products) {
|
|
2513
|
+
if (!Array.isArray(products)) return [];
|
|
2514
|
+
return products.map((p) => this.normalizeProductForRealMode(p));
|
|
2439
2515
|
}
|
|
2440
2516
|
|
|
2441
2517
|
/**
|
|
@@ -2474,12 +2550,9 @@ class DevServer {
|
|
|
2474
2550
|
);
|
|
2475
2551
|
widgetProducts = sorted.slice(0, limit);
|
|
2476
2552
|
} else {
|
|
2477
|
-
// Default: return first N products
|
|
2478
2553
|
widgetProducts = products.slice(0, limit);
|
|
2479
2554
|
}
|
|
2480
|
-
|
|
2481
|
-
// Enrich all products with required data before returning
|
|
2482
|
-
return this.enrichProductsData(widgetProducts);
|
|
2555
|
+
return widgetProducts;
|
|
2483
2556
|
}
|
|
2484
2557
|
|
|
2485
2558
|
/**
|
|
@@ -2538,7 +2611,7 @@ class DevServer {
|
|
|
2538
2611
|
const products = context.collection?.products || [];
|
|
2539
2612
|
const productCards = products.map(p => `
|
|
2540
2613
|
<div style="background: #fff; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
|
|
2541
|
-
<a href="
|
|
2614
|
+
<a href="/${p.slug || p.handle || p.id}">
|
|
2542
2615
|
<img src="${p.images?.[0] || p.imageUrl || 'https://picsum.photos/seed/' + p.id + '/300/300'}"
|
|
2543
2616
|
alt="${p.title || p.name}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;">
|
|
2544
2617
|
<h4 style="margin: 0.5rem 0;">${p.title || p.name}</h4>
|
|
@@ -3284,6 +3357,22 @@ class DevServer {
|
|
|
3284
3357
|
logLevel: 'silent',
|
|
3285
3358
|
timeout: 2000
|
|
3286
3359
|
}));
|
|
3360
|
+
} else if (this.mode === 'real') {
|
|
3361
|
+
const base = (process.env.O2VEND_API_BASE_URL || '').replace(/\/$/, '').replace(/\/shopfront\/api\/v2\/?$/i, '').replace(/\/webstoreapi\/?$/i, '');
|
|
3362
|
+
if (base) {
|
|
3363
|
+
const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
3364
|
+
const proxyOpts = {
|
|
3365
|
+
target: base,
|
|
3366
|
+
changeOrigin: true,
|
|
3367
|
+
logLevel: 'silent',
|
|
3368
|
+
timeout: parseInt(process.env.O2VEND_API_TIMEOUT, 10) || 10000,
|
|
3369
|
+
onProxyReq: (proxyReq, req) => {
|
|
3370
|
+
if (req.headers.cookie) proxyReq.setHeader('Cookie', req.headers.cookie);
|
|
3371
|
+
}
|
|
3372
|
+
};
|
|
3373
|
+
this.app.use('/webstoreapi', createProxyMiddleware(proxyOpts));
|
|
3374
|
+
this.app.use('/shopfront/api', createProxyMiddleware(proxyOpts));
|
|
3375
|
+
}
|
|
3287
3376
|
}
|
|
3288
3377
|
|
|
3289
3378
|
// Inject hot reload script into HTML responses
|