@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.
@@ -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(normalizedPath);
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
- if (!tenantId || !apiKey || !baseUrl) {
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 = new O2VendApiClient(tenantId, apiKey, baseUrl);
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
- // Create widget service (works with mock or real API)
253
- // Note: In mock mode, this will be recreated after mock API starts
254
- if (this.apiClient) {
255
- this.widgetService = new WidgetService(this.apiClient, {
256
- theme: path.basename(this.themePath),
257
- themePath: this.themePath
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?.length || 0}`);
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.apiClient) {
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.widgetService) {
440
- const widgets = await this.widgetService.getWidgetsBySections();
441
- context.widgets = widgets || { hero: [], content: [], footer: [] };
442
- console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
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
- console.error('[HOME] Widget service not available');
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
- // Find category by name or handle (case-insensitive)
475
- const category = context.categories?.find(c =>
476
- (c.name && c.name.toLowerCase() === categoryFilter.toLowerCase()) ||
477
- (c.handle && c.handle.toLowerCase() === categoryFilter.toLowerCase())
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
- // Filter products by categoryId and enrich them
484
- const filteredProducts = context.products
485
- .filter(p =>
486
- p.categoryId === category.id ||
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
- // Ensure all products have proper data: URL, name/title, price, images
511
- // Only enrich if not already enriched (check if first product has url field)
512
- if (context.products && context.products.length > 0 && !context.products[0].url && !context.products[0].link) {
513
- console.log(`[PRODUCTS PAGE] Products need enrichment - enriching now...`);
514
- context.products = this.enrichProductsData(context.products);
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.thumbnailImage1)
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?.length || 0}`);
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
- // Find category and filter by categoryId
549
- const category = context.categories?.find(c =>
550
- (c.name && c.name.toLowerCase() === categoryFilter.toLowerCase()) ||
551
- (c.handle && c.handle.toLowerCase() === categoryFilter.toLowerCase())
552
- );
553
- if (category) {
554
- queryParams.categoryId = category.id;
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.thumbnailImage1 || sampleProduct.imageUrl || (sampleProduct.images && sampleProduct.images.length > 0);
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] ⚠️ No widgets in context, attempting to reload...');
614
+ console.warn('[PRODUCTS PAGE] No widgets in context, attempting reload...');
600
615
  try {
601
- if (this.widgetService) {
602
- const widgets = await this.widgetService.getWidgetsBySections();
603
- context.widgets = widgets || { hero: [], content: [], footer: [], header: [] };
604
- console.log(`[PRODUCTS PAGE] Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
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
- console.error('[PRODUCTS PAGE] Widget service not available');
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] Failed to reload widgets:', error.message);
611
- context.widgets = { hero: [], content: [], footer: [], header: [] };
625
+ console.error('[PRODUCTS PAGE] Failed to reload widgets:', error.message);
626
+ context.widgets = { header: [], hero: [], content: [], footer: [] };
612
627
  }
613
628
  }
614
-
615
- // Ensure menus are available
616
- if (!context.menus || context.menus.length === 0) {
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.thumbnailImage1),
667
- imageUrl: sample.imageUrl || sample.thumbnailImage1?.url || 'MISSING',
668
- hasThumbnailImage1: !!sample.thumbnailImage1,
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
- stock: firstProduct.stock,
694
+ stockQuantity: firstProduct.stockQuantity,
691
695
  inStock: firstProduct.inStock,
692
- hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage1),
693
- imageUrl: firstProduct.imageUrl || firstProduct.thumbnailImage1?.url
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
- // Collections listing page (uses categories.liquid template per webstore)
846
- this.app.get('/collections', async (req, res, next) => {
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
- // Set category in context based on handle
877
- const category = context.categories?.find(c =>
878
- c.handle === req.params.handle ||
879
- c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
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
- // Filter products by this category and enrich them
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; // Also set in products array for consistency
896
-
871
+ context.products = filteredProducts;
897
872
  console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
898
873
  } else {
899
- // No category found, use all products with enrichment
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 category = context.categories?.find(c =>
960
- c.handle === req.params.handle ||
961
- c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
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
- // Filter products by this category and enrich them
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; // Also set in products array for consistency
978
-
947
+ context.products = filteredProducts;
979
948
  console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
980
949
  } else {
981
- // No category found, use all products with enrichment
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 from shopfront API (mock)
1078
+ // Load page content
1111
1079
  try {
1112
- const axios = require('axios');
1113
- const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
1114
- if (pageResponse.data) {
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
- ...pageResponse.data,
1132
- body_html: pageResponse.data.content,
1133
- content: pageResponse.data.content
1085
+ ...pageData,
1086
+ body_html: pageData.content || pageData.body_html,
1087
+ content: pageData.content || pageData.body_html
1134
1088
  };
1135
1089
  }
1136
- } catch (fallbackError) {
1137
- context.page = {
1138
- ...context.page,
1139
- title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
1140
- handle: req.params.handle,
1141
- body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`,
1142
- content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`
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
- const axios = require('axios');
1174
- const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1175
- if (pageResponse.data) {
1176
- context.page = {
1177
- ...context.page,
1178
- ...pageResponse.data,
1179
- body_html: pageResponse.data.content,
1180
- content: pageResponse.data.content
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.apiClient) {
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 category = context.categories?.find(c =>
1413
- (c.handle && c.handle.toLowerCase() === handle.toLowerCase()) ||
1414
- (c.name && c.name.toLowerCase().replace(/\s+/g, '-') === handle.toLowerCase())
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
- // Filter products by this category and enrich them
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.thumbnailImage1),
1450
- imageUrl: product.imageUrl || product.thumbnailImage1?.url
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.thumbnailImage1),
1480
- imageUrl: sample.imageUrl || sample.thumbnailImage1?.url,
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
- stock: firstProduct.stock,
1509
+ stockQuantity: firstProduct.stockQuantity,
1506
1510
  inStock: firstProduct.inStock,
1507
- hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage1)
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
- // Filter products by this brand and enrich them
1543
- const filteredProducts = (context.products || [])
1544
- .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
- .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 found - pass to next middleware (will eventually 404)
1608
- next();
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
- // If there's an error, pass to next middleware
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
- res.status(204).end(); // No Content
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.getWidgetsBySections()
1715
+ * - Widgets are loaded via widgetService.fetchPageWidgets(pageId)
1699
1716
  * - Widgets are organized by section (header, hero, content, footer)
1700
- * - Widgets should be available for ALL page types (home, products, collection, etc.)
1701
- * - If widgets are missing, check:
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
- itemCount: 0,
1735
- currency: 'USD',
1736
- subtotal: 0,
1737
- tax: 0,
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
- context.shop = storeInfo || {
1781
- name: 'My Store',
1782
- description: 'O2VEND Store',
1783
- domain: 'localhost:3000',
1784
- email: 'store@example.com'
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
- // Get widgets for sections from mock API
1788
- // NOTE: Widgets should be loaded for ALL page types (home, products, collection, etc.)
1789
- // The widget service filters widgets by section, so all sections should be available
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
- const widgets = await this.widgetService.getWidgetsBySections();
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] Failed to load widgets:', error.message);
1818
- console.error('[CONTEXT] Error stack:', error.stack);
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
- console.warn('[CONTEXT] ⚠️ Widget service not available, using empty widgets');
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.thumbnailImage1),
1864
- imageUrl: product.imageUrl || product.thumbnailImage1?.url || 'MISSING'
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 firstProduct = context.products[0];
1874
- const hasStock = 'stock' in firstProduct || 'quantity' in firstProduct;
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
- // Set URL to category handle (e.g., /accessories)
1899
- if (!category.url && !category.link) {
1900
- const handle = category.handle || category.name?.toLowerCase().replace(/\s+/g, '-') || category.id;
1901
- category.url = `/${handle}`;
1902
- category.link = `/${handle}`;
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 menusData = menusResponse.data;
1933
- context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
1934
- console.log(`[CONTEXT] ✅ Loaded ${context.menus.length} menus`);
1935
-
1936
- // DEBUG: Log menu details
1937
- if (context.menus.length > 0) {
1938
- context.menus.forEach((menu, idx) => {
1939
- const itemCount = menu.items?.length || 0;
1940
- console.log(`[CONTEXT DEBUG] Menu ${idx + 1}: ${menu.name || menu.id || 'unnamed'} (${itemCount} items)`);
1941
- });
1942
- } else {
1943
- console.warn('[CONTEXT DEBUG] ⚠️ No menus loaded - menus array is empty');
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] Failed to load menus:', error.message);
1947
- console.error('[CONTEXT] Error details:', error.response?.status, error.response?.data);
1948
- context.menus = [];
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
- description: 'O2VEND Store',
2036
- domain: 'localhost:3000',
2037
- email: 'store@example.com'
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
- products: [],
2062
+ content: [],
2042
2063
  footer: [],
2043
- content: []
2064
+ header: []
2044
2065
  };
2045
2066
  }
2046
- } else if (this.apiClient) {
2047
- // Use real API
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
- const storeInfo = await this.apiClient.getStoreInfo(true);
2050
- context.shop = storeInfo;
2051
- context.tenant = { id: this.apiClient.tenantId };
2052
-
2053
- // Get widgets for sections
2054
- const widgets = await this.widgetService.getWidgetsBySections();
2055
- context.widgets = widgets;
2056
-
2057
- // Ensure all widgets have template_path set for rendering
2058
- Object.keys(widgets).forEach(section => {
2059
- if (Array.isArray(widgets[section])) {
2060
- widgets[section].forEach(widget => {
2061
- if (!widget.template_path && widget.template) {
2062
- widget.template_path = `widgets/${widget.template}`;
2063
- }
2064
- if (!widget.template_path && widget.type) {
2065
- const templateSlug = this.widgetService.getTemplateSlug(widget.type);
2066
- widget.template_path = `widgets/${templateSlug}`;
2067
- widget.template = templateSlug;
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
- // Load page-specific data
2074
- if (pageType === 'products' || pageType === 'home') {
2075
- // Load products for products listing page or homepage
2076
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
2077
- context.products = productsResponse.products || [];
2078
- // For products page, also set collection.products
2079
- if (pageType === 'products') {
2080
- context.collection = context.collection || {};
2081
- context.collection.products = context.products;
2082
- context.collection.title = 'All Products';
2083
- context.collection.handle = 'all';
2084
- context.collection.totalProducts = context.products.length;
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
- } else if (pageType === 'product' && extra.productHandle) {
2087
- // Load product data
2088
- // context.product = await this.loadProduct(extra.productHandle);
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] Failed to load from API:', error.message);
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
- // Products are already enriched by getProductsForWidget, but ensure they're properly formatted
2138
- const enrichedProducts = this.enrichProductsData(widgetProducts);
2139
-
2140
- // Set products at multiple levels for template compatibility
2141
- widget.data.products = enrichedProducts;
2142
- widget.data.Products = enrichedProducts;
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 = enrichedProducts;
2148
- widget.data.content.Products = enrichedProducts;
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 ${enrichedProducts.length} enriched products`);
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 recentProducts = this.enrichProductsData(products.slice(0, 6));
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} enriched recently viewed products`);
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
- if (numericMatch) {
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
- // Ensure URL field exists - CRITICAL for product navigation
2257
- // Treat '#' as missing URL and regenerate it
2258
- const hasValidUrl = enriched.url && enriched.url !== '#' && enriched.url !== '';
2259
- const hasValidLink = enriched.link && enriched.link !== '#' && enriched.link !== '';
2260
-
2261
- if (!hasValidUrl && !hasValidLink) {
2262
- const handle = enriched.handle || enriched.slug || enriched.id;
2263
- if (handle && handle !== '#' && handle !== '') {
2264
- enriched.url = `/products/${handle}`;
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 || `Product ${enriched.id || 'Unknown'}`;
2405
+ enriched.name = enriched.title;
2331
2406
  } else if (!enriched.title) {
2332
2407
  enriched.title = enriched.name;
2333
2408
  }
2334
-
2335
- // Ensure both variations and variants exist (product-card uses variations)
2336
- if (enriched.variants && !enriched.variations) {
2337
- enriched.variations = enriched.variants;
2338
- } else if (enriched.variations && !enriched.variants) {
2339
- enriched.variants = enriched.variations;
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
- // Ensure prices object exists for product-card compatibility - CRITICAL
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: finalPrice,
2371
- mrp: mrpValue,
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
- if (enriched.sellingPrice === undefined || enriched.sellingPrice === null) {
2392
- enriched.sellingPrice = finalPrice;
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.thumbnailImage1 = {
2401
- url: enriched.imageUrl,
2402
- altText: enriched.title || enriched.name || 'Product image'
2403
- };
2429
+ enriched.thumbnailImage = enriched.imageUrl;
2404
2430
  }
2405
-
2406
- // Ensure stock/availability data exists - CRITICAL to prevent incorrect "SOLD OUT" display
2407
- if (enriched.stock === undefined && enriched.stockQuantity === undefined) {
2408
- // Calculate from variants/variations if available
2409
- const variantsOrVariations = enriched.variants || enriched.variations;
2410
- if (variantsOrVariations && variantsOrVariations.length > 0) {
2411
- const totalStock = variantsOrVariations.reduce((sum, v) => sum + (v.stock || v.quantity || 0), 0);
2412
- enriched.stock = totalStock;
2413
- enriched.stockQuantity = totalStock;
2414
- enriched.inStock = totalStock > 0;
2415
- enriched.available = totalStock > 0;
2416
- } else {
2417
- // Default to in stock if no stock info (prevents false "SOLD OUT" display)
2418
- enriched.stock = 10;
2419
- enriched.stockQuantity = 10;
2420
- enriched.inStock = true;
2421
- enriched.available = true;
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
- } else {
2424
- // Ensure inStock/available flags are set based on stock value
2425
- // CRITICAL: Use nullish coalescing to handle 0 values correctly
2426
- const stockValue = enriched.stock !== undefined ? enriched.stock : (enriched.stockQuantity !== undefined ? enriched.stockQuantity : 0);
2427
- enriched.inStock = stockValue > 0;
2428
- enriched.available = stockValue > 0;
2429
-
2430
- // Also ensure stockQuantity is set if only stock is set (and vice versa)
2431
- if (enriched.stock !== undefined && enriched.stockQuantity === undefined) {
2432
- enriched.stockQuantity = enriched.stock;
2433
- } else if (enriched.stockQuantity !== undefined && enriched.stock === undefined) {
2434
- enriched.stock = enriched.stockQuantity;
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
- return enriched;
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="/products/${p.handle || p.id}">
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