@o2vend/theme-cli 1.0.33 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Development Server
3
3
  * Express server for local theme development with hot reload
4
+ *
5
+ * Developer guideline: Do NOT add theme-specific CSS class hardcodes (e.g. targeting
6
+ * .mobile-bottom-nav, .site-header, etc.) in this file. Such fixes would force theme
7
+ * developers to use those exact class names. Theme behavior and styling must remain
8
+ * entirely under the theme; the CLI only serves assets, proxies, and generic tooling.
4
9
  */
5
10
 
6
11
  const express = require('express');
@@ -11,6 +16,7 @@ const WidgetService = require('./widget-service');
11
16
  const O2VendApiClient = require('./api-client');
12
17
  const { setupHotReload } = require('./hot-reload');
13
18
  const chalk = require('chalk');
19
+ const webstoreApi = require('./webstoreapi-fetcher');
14
20
 
15
21
  class DevServer {
16
22
  constructor(options = {}) {
@@ -130,29 +136,33 @@ class DevServer {
130
136
  // Only process if it contains actual Liquid variables, not just comments
131
137
  if (cssContent.includes('{{') && cssContent.includes('settings.')) {
132
138
  try {
133
- // Load settings from theme config (already extracts 'current' section)
134
139
  const settings = this.loadThemeSettings();
135
-
136
- // Process through Liquid
137
140
  const processedCss = await this.liquid.parseAndRender(cssContent, { settings });
138
-
139
141
  res.setHeader('Content-Type', 'text/css; charset=utf-8');
140
142
  res.setHeader('Cache-Control', 'no-cache');
141
143
  return res.send(processedCss);
142
144
  } catch (liquidError) {
143
145
  console.error(`[CSS] Liquid processing error for ${relativePath}:`, liquidError.message);
144
- // Fall through to serve static file
145
146
  }
146
147
  }
147
148
 
148
- // Serve static file if no Liquid processing needed or if processing failed
149
149
  res.setHeader('Content-Type', 'text/css; charset=utf-8');
150
- res.sendFile(normalizedPath);
150
+ res.sendFile(relativePath, { root: path.join(this.themePath, 'assets') });
151
151
  } catch (error) {
152
152
  console.error(`[CSS] Error processing ${req.path}:`, error.message);
153
153
  next();
154
154
  }
155
155
  });
156
+
157
+ // Explicit logo route so /assets/logo.png always serves correctly (avoids broken image)
158
+ this.app.get('/assets/logo.png', (req, res) => {
159
+ const logoPath = path.join(this.themePath, 'assets', 'logo.png');
160
+ if (!fs.existsSync(logoPath)) {
161
+ return res.status(404).setHeader('Content-Type', 'text/plain').end('Logo not found');
162
+ }
163
+ res.setHeader('Content-Type', 'image/png');
164
+ res.sendFile('logo.png', { root: path.join(this.themePath, 'assets') });
165
+ });
156
166
 
157
167
  // Static files (theme assets) with proper MIME types
158
168
  this.app.use('/assets', express.static(path.join(this.themePath, 'assets'), {
@@ -225,37 +235,36 @@ class DevServer {
225
235
 
226
236
  /**
227
237
  * Setup API client and widget service
238
+ * Real mode: no apiClient (use webstoreapi-fetcher only). WidgetService with null for getTemplateSlug.
239
+ * Mock mode: apiClient created in start() after mock API; WidgetService uses it.
228
240
  */
229
241
  async setupServices() {
230
242
  if (this.mode === 'real') {
231
- // Real API mode - create API client from environment
232
- const tenantId = process.env.O2VEND_TENANT_ID;
233
- const apiKey = process.env.O2VEND_API_KEY;
234
243
  const baseUrl = process.env.O2VEND_API_BASE_URL;
235
-
236
- if (!tenantId || !apiKey || !baseUrl) {
237
- console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_TENANT_ID, O2VEND_API_KEY, and O2VEND_API_BASE_URL'));
244
+ if (!baseUrl) {
245
+ console.warn(chalk.yellow('⚠️ Real API mode requires O2VEND_API_BASE_URL (storefront URL, e.g. https://store.myo2vend.com)'));
238
246
  console.warn(chalk.yellow(' Falling back to mock mode'));
239
247
  this.mode = 'mock';
240
248
  } else {
241
- this.apiClient = new O2VendApiClient(tenantId, apiKey, baseUrl);
249
+ this.apiClient = null;
250
+ this.widgetService = new WidgetService(null, {
251
+ theme: path.basename(this.themePath),
252
+ themePath: this.themePath
253
+ });
242
254
  }
243
255
  }
244
-
245
- // In mock mode, create API client - will be updated after mock API starts
246
- // We create a placeholder here, actual client will be created in start() after mock API is running
256
+
247
257
  if (this.mode === 'mock') {
248
- // Create a temporary client - will be replaced in start() method
249
258
  this.apiClient = null;
250
259
  }
251
-
252
- // 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
- });
260
+
261
+ if (this.mode === 'mock' || !this.widgetService) {
262
+ if (this.apiClient) {
263
+ this.widgetService = new WidgetService(this.apiClient, {
264
+ theme: path.basename(this.themePath),
265
+ themePath: this.themePath
266
+ });
267
+ }
259
268
  }
260
269
 
261
270
  // Create Liquid engine
@@ -420,9 +429,15 @@ class DevServer {
420
429
  if (!context.products || context.products.length === 0) {
421
430
  console.warn('[HOME] No products in context, attempting reload...');
422
431
  try {
423
- if (this.apiClient) {
432
+ if (this.mode === 'real') {
433
+ const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
434
+ context.products = res?.products || res?.Products || [];
435
+ context.products = this.enrichProductsData(context.products);
436
+ console.log(`[HOME] Reloaded ${context.products.length} products (real)`);
437
+ } else if (this.apiClient) {
424
438
  const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
425
439
  context.products = productsResponse.products || productsResponse.data?.products || [];
440
+ context.products = this.enrichProductsData(context.products);
426
441
  console.log(`[HOME] Reloaded ${context.products.length} products`);
427
442
  } else {
428
443
  console.error('[HOME] API client not available');
@@ -431,12 +446,15 @@ class DevServer {
431
446
  console.error('[HOME] Failed to reload products:', error.message);
432
447
  }
433
448
  }
434
-
435
- // Ensure widgets are available
449
+
436
450
  if (!context.widgets || Object.keys(context.widgets).length === 0) {
437
451
  console.warn('[HOME] No widgets in context, attempting reload...');
438
452
  try {
439
- if (this.widgetService) {
453
+ if (this.mode === 'real') {
454
+ const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
455
+ context.widgets = await webstoreApi.fetchWidgetsBySections('home', webstoreApi.DEFAULT_SECTIONS, opts) || { hero: [], content: [], footer: [] };
456
+ console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')} (real)`);
457
+ } else if (this.widgetService) {
440
458
  const widgets = await this.widgetService.getWidgetsBySections();
441
459
  context.widgets = widgets || { hero: [], content: [], footer: [] };
442
460
  console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
@@ -449,7 +467,7 @@ class DevServer {
449
467
  context.widgets = { hero: [], content: [], footer: [] };
450
468
  }
451
469
  }
452
-
470
+
453
471
  const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
454
472
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
455
473
  res.setHeader('Cache-Control', 'no-cache');
@@ -462,8 +480,8 @@ class DevServer {
462
480
  // Products listing page
463
481
  this.app.get('/products', async (req, res, next) => {
464
482
  try {
465
- // Check for category filter in query parameters
466
- const categoryFilter = req.query.category;
483
+ // Check for category filter in query parameters (handle both 'category' and 'Category')
484
+ const categoryFilter = req.query.category || req.query.Category;
467
485
 
468
486
  let context = await this.buildContext(req, 'products');
469
487
 
@@ -471,44 +489,61 @@ class DevServer {
471
489
  if (categoryFilter) {
472
490
  console.log(`[PRODUCTS PAGE] Filtering by category: ${categoryFilter}`);
473
491
 
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
- );
492
+ const cf = (categoryFilter || '').toLowerCase();
493
+ const category = context.categories?.find(c => {
494
+ const ch = (c.handle || '').toLowerCase();
495
+ const cs = (c.slug || '').toLowerCase();
496
+ const cn = (c.name || '').toLowerCase();
497
+ const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
498
+ return ch === cf || cs === cf || cn === cf || cnNorm === cf;
499
+ });
479
500
 
480
501
  if (category) {
481
502
  console.log(`[PRODUCTS PAGE] Found category: ${category.name} (ID: ${category.id})`);
482
503
 
483
- // Filter products by categoryId
484
- const filteredProducts = context.products.filter(p =>
485
- p.categoryId === category.id ||
486
- String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
487
- );
504
+ const filtered = context.products.filter(p => this.productMatchesCategory(p, category));
505
+ const filteredProducts = this.mode === 'real'
506
+ ? filtered
507
+ : filtered.map(p => this.enrichProductData(p));
488
508
 
509
+ // CRITICAL: Set both context.products and context.collection.products to the SAME enriched array
510
+ // This ensures they always reference the same objects (like widgets do)
489
511
  context.products = filteredProducts;
490
- console.log(`[PRODUCTS PAGE] Filtered products: ${filteredProducts.length} products in category`);
491
-
492
- // Update collection context
493
512
  context.collection = context.collection || {};
513
+ context.collection.products = context.products; // Same reference as context.products
494
514
  context.collection.title = category.name || 'Products';
495
515
  context.collection.handle = category.handle || categoryFilter;
496
516
  context.collection.description = category.description;
497
517
  context.collection.totalProducts = filteredProducts.length;
518
+
519
+ console.log(`[PRODUCTS PAGE] Filtered products: ${filteredProducts.length} products in category`);
520
+ console.log(`[PRODUCTS PAGE] ✅ collection.products === context.products: ${context.collection.products === context.products}`);
521
+
522
+ console.log(`[PRODUCTS PAGE] ✅ Category filtered: ${filteredProducts.length} enriched products in collection`);
498
523
  } else {
499
524
  console.warn(`[PRODUCTS PAGE] Category not found: ${categoryFilter}, showing all products`);
500
525
  }
501
526
  }
502
527
 
503
- // Ensure all products have proper URL field if missing
504
- context.products = (context.products || []).map(product => {
505
- if (!product.url && !product.link) {
506
- const handle = product.handle || product.slug || product.id;
507
- product.url = `/products/${handle}`;
508
- product.link = `/products/${handle}`;
528
+ if (context.products && context.products.length > 0 && this.mode !== 'real') {
529
+ if (!context.products[0].url && !context.products[0].link) {
530
+ console.log(`[PRODUCTS PAGE] Products need enrichment - enriching now...`);
531
+ context.products = this.enrichProductsData(context.products);
509
532
  }
510
- return product;
511
- });
533
+ }
534
+
535
+ // Debug: Log sample product data to verify enrichment
536
+ if (context.products.length > 0) {
537
+ const sample = context.products[0];
538
+ console.log(`[PRODUCTS PAGE] Sample product data:`, {
539
+ title: sample.title || sample.name,
540
+ url: sample.url || sample.link,
541
+ price: sample.price || sample.sellingPrice,
542
+ stock: sample.stock,
543
+ inStock: sample.inStock,
544
+ hasImage: !!(sample.imageUrl || sample.thumbnailImage1)
545
+ });
546
+ }
512
547
 
513
548
  // DEBUG: Log context data for /products page
514
549
  console.log(`[PRODUCTS PAGE] Context summary:`);
@@ -523,31 +558,37 @@ class DevServer {
523
558
  if (!context.products || context.products.length === 0) {
524
559
  console.warn('[PRODUCTS PAGE] ⚠️ No products in context, attempting to reload...');
525
560
  try {
526
- const queryParams = {};
561
+ const queryParams = { limit: 50, offset: 0 };
527
562
  if (categoryFilter) {
528
- // Find category and filter by categoryId
529
- const category = context.categories?.find(c =>
530
- (c.name && c.name.toLowerCase() === categoryFilter.toLowerCase()) ||
531
- (c.handle && c.handle.toLowerCase() === categoryFilter.toLowerCase())
532
- );
533
- if (category) {
534
- queryParams.categoryId = category.id;
535
- }
563
+ const cf = (categoryFilter || '').toLowerCase();
564
+ const cat = context.categories?.find(c => {
565
+ const ch = (c.handle || '').toLowerCase();
566
+ const cs = (c.slug || '').toLowerCase();
567
+ const cn = (c.name || '').toLowerCase();
568
+ const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
569
+ return ch === cf || cs === cf || cn === cf || cnNorm === cf;
570
+ });
571
+ if (cat) queryParams.categoryId = cat.id;
572
+ }
573
+ if (this.mode === 'real') {
574
+ const res = await webstoreApi.fetchProducts(queryParams, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
575
+ context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
576
+ } else if (this.apiClient) {
577
+ const productsResponse = await this.apiClient.getProducts(queryParams);
578
+ context.products = productsResponse.products || productsResponse.data?.products || [];
579
+ context.products = this.enrichProductsData(context.products);
536
580
  }
537
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0, ...queryParams });
538
- context.products = productsResponse.products || productsResponse.data?.products || [];
539
-
540
- // Ensure URLs are set
541
- context.products = context.products.map(product => {
542
- if (!product.url && !product.link) {
543
- const handle = product.handle || product.slug || product.id;
544
- product.url = `/products/${handle}`;
545
- product.link = `/products/${handle}`;
546
- }
547
- return product;
548
- });
549
-
550
581
  console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.products.length} products`);
582
+ if (context.products.length > 0) {
583
+ const sample = context.products[0];
584
+ console.log(`[PRODUCTS PAGE] Sample reloaded product:`, {
585
+ title: sample.title || sample.name,
586
+ url: sample.url || sample.link,
587
+ price: sample.price || sample.sellingPrice,
588
+ stock: sample.stock,
589
+ inStock: sample.inStock
590
+ });
591
+ }
551
592
  } catch (error) {
552
593
  console.error('[PRODUCTS PAGE] ❌ Failed to load products:', error.message);
553
594
  }
@@ -575,12 +616,15 @@ class DevServer {
575
616
  if (!context.widgets || Object.keys(context.widgets).length === 0) {
576
617
  console.warn('[PRODUCTS PAGE] ⚠️ No widgets in context, attempting to reload...');
577
618
  try {
578
- if (this.widgetService) {
619
+ if (this.mode === 'real') {
620
+ const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
621
+ context.widgets = await webstoreApi.fetchWidgetsBySections('products', webstoreApi.DEFAULT_SECTIONS, opts) || { hero: [], content: [], footer: [], header: [] };
622
+ console.log(`[PRODUCTS PAGE] ✅ Reloaded widgets: ${Object.keys(context.widgets).join(', ')} (real)`);
623
+ } else if (this.widgetService) {
579
624
  const widgets = await this.widgetService.getWidgetsBySections();
580
625
  context.widgets = widgets || { hero: [], content: [], footer: [], header: [] };
581
626
  console.log(`[PRODUCTS PAGE] ✅ Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
582
627
  } else {
583
- console.error('[PRODUCTS PAGE] ❌ Widget service not available');
584
628
  context.widgets = { hero: [], content: [], footer: [], header: [] };
585
629
  }
586
630
  } catch (error) {
@@ -588,16 +632,22 @@ class DevServer {
588
632
  context.widgets = { hero: [], content: [], footer: [], header: [] };
589
633
  }
590
634
  }
591
-
635
+
592
636
  // Ensure menus are available
593
637
  if (!context.menus || context.menus.length === 0) {
594
638
  console.warn('[PRODUCTS PAGE] ⚠️ No menus in context, attempting to reload...');
595
639
  try {
596
- const axios = require('axios');
597
- const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
598
- const menusData = menusResponse.data;
599
- context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
600
- console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus`);
640
+ if (this.mode === 'real') {
641
+ context.menus = await webstoreApi.fetchMenus({ headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
642
+ context.menus = Array.isArray(context.menus) ? context.menus : [];
643
+ console.log(`[PRODUCTS PAGE] Reloaded ${context.menus.length} menus (real)`);
644
+ } else {
645
+ const axios = require('axios');
646
+ const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
647
+ const menusData = menusResponse.data;
648
+ context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
649
+ console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus`);
650
+ }
601
651
  } catch (error) {
602
652
  console.error('[PRODUCTS PAGE] ❌ Failed to reload menus:', error.message);
603
653
  context.menus = [];
@@ -605,9 +655,14 @@ class DevServer {
605
655
  }
606
656
 
607
657
  // Ensure collection.products is set for products template
658
+ // CRITICAL: Always sync collection.products with context.products AFTER enrichment
659
+ // This ensures collection.products always has the same enriched objects as context.products
608
660
  if (context.products && context.products.length > 0) {
661
+ // Always reassign collection.products to match context.products (they should be the same)
609
662
  context.collection = context.collection || {};
610
- context.collection.products = context.products;
663
+ context.collection.products = context.products; // Use the same enriched array
664
+
665
+ // Update collection metadata if not set
611
666
  if (!context.collection.title) {
612
667
  context.collection.title = categoryFilter ? `${categoryFilter} Products` : 'All Products';
613
668
  }
@@ -617,6 +672,56 @@ class DevServer {
617
672
  if (!context.collection.totalProducts) {
618
673
  context.collection.totalProducts = context.products.length;
619
674
  }
675
+
676
+ // Debug: Verify collection.products has same reference as context.products
677
+ const sameReference = context.collection.products === context.products;
678
+ console.log(`[PRODUCTS PAGE] collection.products sync: same reference=${sameReference}, length=${context.collection.products?.length || 0}`);
679
+
680
+ // Debug: Log sample product from collection to verify enrichment
681
+ if (context.collection.products && context.collection.products.length > 0) {
682
+ const sample = context.collection.products[0];
683
+ console.log(`[PRODUCTS PAGE] ✅ Sample collection product before render:`, {
684
+ id: sample.id,
685
+ title: sample.title || sample.name,
686
+ url: sample.url || sample.link || 'MISSING',
687
+ price: sample.price || sample.sellingPrice || 'MISSING',
688
+ prices: sample.prices ? { price: sample.prices.price, mrp: sample.prices.mrp } : 'MISSING',
689
+ stock: sample.stock !== undefined ? sample.stock : 'MISSING',
690
+ stockQuantity: sample.stockQuantity !== undefined ? sample.stockQuantity : 'MISSING',
691
+ inStock: sample.inStock !== undefined ? sample.inStock : 'MISSING',
692
+ available: sample.available !== undefined ? sample.available : 'MISSING',
693
+ hasImage: !!(sample.imageUrl || sample.thumbnailImage1),
694
+ imageUrl: sample.imageUrl || sample.thumbnailImage1?.url || 'MISSING',
695
+ hasThumbnailImage1: !!sample.thumbnailImage1,
696
+ hasImagesArray: !!(sample.images && sample.images.length > 0)
697
+ });
698
+ } else {
699
+ console.warn(`[PRODUCTS PAGE] ⚠️ collection.products is empty or undefined!`);
700
+ console.warn(`[PRODUCTS PAGE] context.products.length: ${context.products?.length || 0}`);
701
+ console.warn(`[PRODUCTS PAGE] context.collection:`, context.collection ? Object.keys(context.collection) : 'undefined');
702
+ }
703
+ } else {
704
+ console.warn(`[PRODUCTS PAGE] ⚠️ No products in context!`);
705
+ }
706
+
707
+ // Debug: Log final context before rendering
708
+ console.log(`[PRODUCTS PAGE] Final context before rendering:`);
709
+ console.log(` - Products count: ${context.products?.length || 0}`);
710
+ if (context.products && context.products.length > 0) {
711
+ const firstProduct = context.products[0];
712
+ console.log(` - First product:`, {
713
+ id: firstProduct.id,
714
+ title: firstProduct.title || firstProduct.name,
715
+ url: firstProduct.url || firstProduct.link,
716
+ price: firstProduct.price || firstProduct.sellingPrice,
717
+ stock: firstProduct.stock,
718
+ inStock: firstProduct.inStock,
719
+ hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage1),
720
+ imageUrl: firstProduct.imageUrl || firstProduct.thumbnailImage1?.url
721
+ });
722
+ }
723
+ if (context.collection) {
724
+ console.log(` - Collection: ${context.collection.title || context.collection.name}, Products: ${context.collection.products?.length || 0}`);
620
725
  }
621
726
 
622
727
  const html = await renderWithLayout(this.liquid, 'templates/products', context, this.themePath);
@@ -795,51 +900,31 @@ class DevServer {
795
900
  this.app.get('/collections/:handle', async (req, res, next) => {
796
901
  try {
797
902
  const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
798
- // Set category in context based on handle
799
- const category = context.categories?.find(c =>
800
- c.handle === req.params.handle ||
801
- c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
802
- );
903
+ const h = (req.params.handle || '').toLowerCase();
904
+ const category = context.categories?.find(c => {
905
+ const ch = (c.handle || '').toLowerCase();
906
+ const cs = (c.slug || '').toLowerCase();
907
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
908
+ return ch === h || cs === h || cn === h;
909
+ });
803
910
  if (category) {
804
911
  context.category = category;
805
912
  context.collection = category;
806
-
807
- // Filter products by this category
808
- const filteredProducts = (context.products || []).filter(p =>
809
- p.categoryId === category.id ||
810
- String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
811
- );
812
-
813
- // Ensure all products have proper URL field if missing
814
- filteredProducts.forEach(product => {
815
- if (!product.url && !product.link) {
816
- const handle = product.handle || product.slug || product.id;
817
- product.url = `/products/${handle}`;
818
- product.link = `/products/${handle}`;
819
- }
820
- });
821
-
913
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
914
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
822
915
  context.collection.products = filteredProducts;
823
916
  context.collection.totalProducts = filteredProducts.length;
824
- context.products = filteredProducts; // Also set in products array for consistency
825
-
917
+ context.products = filteredProducts;
826
918
  console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
827
919
  } else {
828
- // No category found, use all products
829
- context.products = (context.products || []).map(product => {
830
- if (!product.url && !product.link) {
831
- const handle = product.handle || product.slug || product.id;
832
- product.url = `/products/${handle}`;
833
- product.link = `/products/${handle}`;
834
- }
835
- return product;
836
- });
920
+ if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
837
921
  context.collection = context.collection || {};
838
922
  context.collection.products = context.products;
839
923
  }
840
924
 
925
+ // Prioritize products template to show product list, not categories list
841
926
  let html;
842
- const templateOptions = ['templates/categories', 'templates/category', 'templates/collection'];
927
+ const templateOptions = ['templates/products', 'templates/collection', 'templates/category', 'templates/categories'];
843
928
  for (const template of templateOptions) {
844
929
  try {
845
930
  html = await renderWithLayout(this.liquid, template, context, this.themePath);
@@ -891,50 +976,31 @@ class DevServer {
891
976
  this.app.get('/categories/:handle', async (req, res, next) => {
892
977
  try {
893
978
  const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
894
- const category = context.categories?.find(c =>
895
- c.handle === req.params.handle ||
896
- c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
897
- );
979
+ const h = (req.params.handle || '').toLowerCase();
980
+ const category = context.categories?.find(c => {
981
+ const ch = (c.handle || '').toLowerCase();
982
+ const cs = (c.slug || '').toLowerCase();
983
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
984
+ return ch === h || cs === h || cn === h;
985
+ });
898
986
  if (category) {
899
987
  context.category = category;
900
988
  context.collection = category;
901
-
902
- // Filter products by this category
903
- const filteredProducts = (context.products || []).filter(p =>
904
- p.categoryId === category.id ||
905
- String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
906
- );
907
-
908
- // Ensure all products have proper URL field if missing
909
- filteredProducts.forEach(product => {
910
- if (!product.url && !product.link) {
911
- const handle = product.handle || product.slug || product.id;
912
- product.url = `/products/${handle}`;
913
- product.link = `/products/${handle}`;
914
- }
915
- });
916
-
989
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
990
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
917
991
  context.collection.products = filteredProducts;
918
992
  context.collection.totalProducts = filteredProducts.length;
919
- context.products = filteredProducts; // Also set in products array for consistency
920
-
993
+ context.products = filteredProducts;
921
994
  console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
922
995
  } else {
923
- // No category found, use all products
924
- context.products = (context.products || []).map(product => {
925
- if (!product.url && !product.link) {
926
- const handle = product.handle || product.slug || product.id;
927
- product.url = `/products/${handle}`;
928
- product.link = `/products/${handle}`;
929
- }
930
- return product;
931
- });
996
+ if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
932
997
  context.collection = context.collection || {};
933
998
  context.collection.products = context.products;
934
999
  }
935
1000
 
1001
+ // Prioritize products template to show product list, not categories list
936
1002
  let html;
937
- const templateOptions = ['templates/categories', 'templates/category', 'templates/collection'];
1003
+ const templateOptions = ['templates/products', 'templates/collection', 'templates/category', 'templates/categories'];
938
1004
  for (const template of templateOptions) {
939
1005
  try {
940
1006
  html = await renderWithLayout(this.liquid, template, context, this.themePath);
@@ -1055,41 +1121,38 @@ class DevServer {
1055
1121
  try {
1056
1122
  const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
1057
1123
 
1058
- // Load page content from shopfront API (mock)
1124
+ // Load page content
1059
1125
  try {
1060
- const axios = require('axios');
1061
- const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
1062
- if (pageResponse.data) {
1063
- context.page = {
1064
- ...context.page,
1065
- ...pageResponse.data,
1066
- body_html: pageResponse.data.content || pageResponse.data.htmlContent,
1067
- content: pageResponse.data.content || pageResponse.data.htmlContent
1068
- };
1069
- }
1070
- } catch (error) {
1071
- console.warn(`[PAGE] Failed to load landing page for ${req.params.handle}:`, error.message);
1072
- // Fallback to mock API pages endpoint
1073
- try {
1074
- const axios = require('axios');
1075
- const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1076
- if (pageResponse.data) {
1126
+ if (this.mode === 'real') {
1127
+ const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
1128
+ if (pageData) {
1077
1129
  context.page = {
1078
1130
  ...context.page,
1079
- ...pageResponse.data,
1080
- body_html: pageResponse.data.content,
1081
- content: pageResponse.data.content
1131
+ ...pageData,
1132
+ body_html: pageData.content || pageData.body_html,
1133
+ content: pageData.content || pageData.body_html
1082
1134
  };
1083
1135
  }
1084
- } catch (fallbackError) {
1085
- context.page = {
1086
- ...context.page,
1087
- title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
1088
- handle: req.params.handle,
1089
- body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`,
1090
- content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`
1091
- };
1136
+ } else {
1137
+ const axios = require('axios');
1138
+ try {
1139
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
1140
+ if (pageResponse.data) {
1141
+ context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content || pageResponse.data.htmlContent, content: pageResponse.data.content || pageResponse.data.htmlContent };
1142
+ }
1143
+ } catch (e) {
1144
+ const fallback = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1145
+ if (fallback.data) {
1146
+ context.page = { ...context.page, ...fallback.data, body_html: fallback.data.content, content: fallback.data.content };
1147
+ }
1148
+ }
1092
1149
  }
1150
+ } catch (error) {
1151
+ console.warn(`[PAGE] Failed to load page for ${req.params.handle}:`, error.message);
1152
+ }
1153
+ if (!context.page.body_html && !context.page.content) {
1154
+ const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
1155
+ context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Landing page content coming soon.</p>`, content: `<h2>${t}</h2><p>Landing page content coming soon.</p>` };
1093
1156
  }
1094
1157
 
1095
1158
  // Try page.liquid template first, then fallback to rendering raw HTML
@@ -1116,30 +1179,27 @@ class DevServer {
1116
1179
  try {
1117
1180
  const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
1118
1181
 
1119
- // Load page content from mock API
1120
1182
  try {
1121
- const axios = require('axios');
1122
- const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1123
- if (pageResponse.data) {
1124
- context.page = {
1125
- ...context.page,
1126
- ...pageResponse.data,
1127
- body_html: pageResponse.data.content,
1128
- content: pageResponse.data.content
1129
- };
1183
+ if (this.mode === 'real') {
1184
+ const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
1185
+ if (pageData) {
1186
+ context.page = { ...context.page, ...pageData, body_html: pageData.content || pageData.body_html, content: pageData.content || pageData.body_html };
1187
+ }
1188
+ } else {
1189
+ const axios = require('axios');
1190
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1191
+ if (pageResponse.data) {
1192
+ context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content, content: pageResponse.data.content };
1193
+ }
1130
1194
  }
1131
1195
  } catch (error) {
1132
1196
  console.warn(`[PAGE] Failed to load page content for ${req.params.handle}:`, error.message);
1133
- // Set default page content
1134
- context.page = {
1135
- ...context.page,
1136
- title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
1137
- handle: req.params.handle,
1138
- body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`,
1139
- content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`
1140
- };
1141
1197
  }
1142
-
1198
+ if (!context.page.body_html && !context.page.content) {
1199
+ const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
1200
+ context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Page content coming soon.</p>`, content: `<h2>${t}</h2><p>Page content coming soon.</p>` };
1201
+ }
1202
+
1143
1203
  const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
1144
1204
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1145
1205
  res.setHeader('Cache-Control', 'no-cache');
@@ -1197,10 +1257,13 @@ class DevServer {
1197
1257
  const widgetType = req.params.type.replace(/\.liquid$/, '');
1198
1258
  const context = await this.buildContext(req, 'home');
1199
1259
 
1200
- // Ensure products are loaded for widget context (needed for product widgets)
1201
1260
  if (!context.products || context.products.length === 0) {
1202
1261
  try {
1203
- if (this.apiClient) {
1262
+ if (this.mode === 'real') {
1263
+ const res = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
1264
+ context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
1265
+ console.log(`[DEV WIDGET] Loaded ${context.products.length} products (real)`);
1266
+ } else if (this.apiClient) {
1204
1267
  const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1205
1268
  context.products = productsResponse.products || productsResponse.data?.products || [];
1206
1269
  console.log(`[DEV WIDGET] Loaded ${context.products.length} products for widget context`);
@@ -1353,44 +1416,143 @@ class DevServer {
1353
1416
  return next();
1354
1417
  }
1355
1418
 
1356
- // Build context to get categories and products
1419
+ // Build context to get categories, brands, and products
1357
1420
  const context = await this.buildContext(req, 'collection', { collectionHandle: handle });
1358
1421
 
1359
- // Try to find category by handle (case-insensitive)
1360
- const category = context.categories?.find(c =>
1361
- (c.handle && c.handle.toLowerCase() === handle.toLowerCase()) ||
1362
- (c.name && c.name.toLowerCase().replace(/\s+/g, '-') === handle.toLowerCase())
1363
- );
1422
+ // Try to find category by handle or slug (case-insensitive)
1423
+ const h = handle.toLowerCase();
1424
+ const category = context.categories?.find(c => {
1425
+ const ch = (c.handle || '').toLowerCase();
1426
+ const cs = (c.slug || '').toLowerCase();
1427
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
1428
+ return ch === h || cs === h || cn === h;
1429
+ });
1364
1430
 
1365
1431
  if (category) {
1366
- // Found a category - render collection page
1367
1432
  context.category = category;
1368
1433
  context.collection = category;
1434
+ console.log(`[ROOT COLLECTION] Found category: ${category.name} (ID: ${category.id})`);
1435
+ console.log(`[ROOT COLLECTION] Total products in context: ${context.products?.length || 0}`);
1436
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
1437
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
1438
+ console.log(`[ROOT COLLECTION] Filtered products count: ${filteredProducts.length}`);
1369
1439
 
1370
- // Filter products by this category
1371
- const filteredProducts = (context.products || []).filter(p =>
1372
- p.categoryId === category.id ||
1373
- String(p.categoryId).toLowerCase() === String(category.id).toLowerCase()
1374
- );
1375
-
1376
- // Ensure all products have proper URL field if missing
1377
- filteredProducts.forEach(product => {
1378
- if (!product.url && !product.link) {
1379
- const productHandle = product.handle || product.slug || product.id;
1380
- product.url = `/products/${productHandle}`;
1381
- product.link = `/products/${productHandle}`;
1382
- }
1383
- });
1440
+ // Log sample enriched products
1441
+ if (filteredProducts.length > 0) {
1442
+ filteredProducts.slice(0, 3).forEach((product, index) => {
1443
+ console.log(`[ROOT COLLECTION] Product ${index} AFTER enrichment:`, {
1444
+ id: product.id,
1445
+ title: product.title || product.name,
1446
+ url: product.url || product.link,
1447
+ price: product.price || product.sellingPrice,
1448
+ stock: product.stock,
1449
+ inStock: product.inStock,
1450
+ hasImage: !!(product.imageUrl || product.thumbnailImage1),
1451
+ imageUrl: product.imageUrl || product.thumbnailImage1?.url
1452
+ });
1453
+ });
1454
+ }
1384
1455
 
1456
+ // Set products in multiple places to ensure templates can access them
1385
1457
  context.collection.products = filteredProducts;
1386
1458
  context.collection.totalProducts = filteredProducts.length;
1387
1459
  context.products = filteredProducts; // Also set in products array for consistency
1388
1460
 
1389
- console.log(`[ROOT COLLECTION] Category: ${category.name}, Products: ${filteredProducts.length}`);
1461
+ // Also set in collection object for template compatibility
1462
+ if (!context.collection.products) {
1463
+ context.collection.products = filteredProducts;
1464
+ }
1465
+
1466
+ console.log(`[ROOT COLLECTION] Final context setup:`);
1467
+ console.log(` - context.products.length: ${context.products?.length || 0}`);
1468
+ console.log(` - context.collection.products.length: ${context.collection.products?.length || 0}`);
1469
+ console.log(` - context.collection.totalProducts: ${context.collection.totalProducts || 0}`);
1390
1470
 
1391
- // Try to render collection template
1471
+ if (filteredProducts.length > 0) {
1472
+ const sample = filteredProducts[0];
1473
+ console.log(`[ROOT COLLECTION] Sample enriched product:`, {
1474
+ id: sample.id,
1475
+ title: sample.title || sample.name,
1476
+ url: sample.url || sample.link,
1477
+ price: sample.price || sample.sellingPrice,
1478
+ stock: sample.stock,
1479
+ inStock: sample.inStock,
1480
+ hasImage: !!(sample.imageUrl || sample.thumbnailImage1),
1481
+ imageUrl: sample.imageUrl || sample.thumbnailImage1?.url,
1482
+ allKeys: Object.keys(sample).slice(0, 20)
1483
+ });
1484
+ } else {
1485
+ console.warn(`[ROOT COLLECTION] ⚠️ No products found for category ${category.name}!`);
1486
+ console.warn(`[ROOT COLLECTION] Available products in context: ${context.products?.length || 0}`);
1487
+ if (context.products && context.products.length > 0) {
1488
+ console.warn(`[ROOT COLLECTION] Sample product categoryIds:`, context.products.slice(0, 3).map(p => ({
1489
+ id: p.id,
1490
+ title: p.title || p.name,
1491
+ categoryId: p.categoryId
1492
+ })));
1493
+ }
1494
+ }
1495
+
1496
+ // Debug: Log final context before rendering
1497
+ console.log(`[ROOT COLLECTION] Final context before rendering:`);
1498
+ console.log(` - Products count: ${context.products?.length || 0}`);
1499
+ if (context.products && context.products.length > 0) {
1500
+ const firstProduct = context.products[0];
1501
+ console.log(` - First product:`, {
1502
+ id: firstProduct.id,
1503
+ title: firstProduct.title || firstProduct.name,
1504
+ url: firstProduct.url || firstProduct.link,
1505
+ price: firstProduct.price || firstProduct.sellingPrice,
1506
+ stock: firstProduct.stock,
1507
+ inStock: firstProduct.inStock,
1508
+ hasImage: !!(firstProduct.imageUrl || firstProduct.thumbnailImage1)
1509
+ });
1510
+ }
1511
+
1512
+ // Try to render products template first (to show product list, not categories list)
1392
1513
  let html;
1393
- const templateOptions = ['templates/categories', 'templates/category', 'templates/collection', 'templates/products'];
1514
+ const templateOptions = ['templates/products', 'templates/collection', 'templates/category', 'templates/categories'];
1515
+ for (const template of templateOptions) {
1516
+ try {
1517
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
1518
+ break;
1519
+ } catch (error) {
1520
+ if (!error.message?.includes('Template not found')) {
1521
+ throw error;
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ if (html) {
1527
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1528
+ res.setHeader('Cache-Control', 'no-cache');
1529
+ return res.send(html);
1530
+ }
1531
+ }
1532
+
1533
+ // Not a category - try to find brand by handle
1534
+ const brand = context.brands?.find(b =>
1535
+ (b.handle && b.handle.toLowerCase() === handle.toLowerCase()) ||
1536
+ (b.name && b.name.toLowerCase().replace(/\s+/g, '-') === handle.toLowerCase())
1537
+ );
1538
+
1539
+ if (brand) {
1540
+ context.brand = brand;
1541
+ const filtered = (context.products || []).filter(p =>
1542
+ p.brandId === brand.id ||
1543
+ String(p.brandId).toLowerCase() === String(brand.id).toLowerCase() ||
1544
+ (p.brand && p.brand.toLowerCase() === brand.name.toLowerCase())
1545
+ );
1546
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
1547
+
1548
+ context.brand.products = filteredProducts;
1549
+ context.products = filteredProducts;
1550
+
1551
+ console.log(`[ROOT BRAND] Brand: ${brand.name}, Products: ${filteredProducts.length}`);
1552
+
1553
+ // Try to render products template
1554
+ let html;
1555
+ const templateOptions = ['templates/products', 'templates/brand', 'templates/collection', 'templates/brands'];
1394
1556
  for (const template of templateOptions) {
1395
1557
  try {
1396
1558
  html = await renderWithLayout(this.liquid, template, context, this.themePath);
@@ -1447,20 +1609,16 @@ class DevServer {
1447
1609
  }
1448
1610
  });
1449
1611
 
1450
- // Favicon handler
1612
+ // Favicon handler: serve from theme assets if present, else 204
1451
1613
  this.app.get('/favicon.ico', (req, res) => {
1452
- res.status(204).end(); // No Content
1614
+ const faviconPath = path.join(this.themePath, 'assets', 'favicon.ico');
1615
+ if (fs.existsSync(faviconPath)) {
1616
+ res.setHeader('Content-Type', 'image/x-icon');
1617
+ return res.sendFile('favicon.ico', { root: path.join(this.themePath, 'assets') });
1618
+ }
1619
+ res.status(204).end();
1453
1620
  });
1454
1621
 
1455
- // API proxy (for real API mode)
1456
- if (this.mode === 'real' && this.apiClient) {
1457
- this.app.use('/api', (req, res, next) => {
1458
- // Proxy API requests to real API
1459
- // This is handled by mock API in mock mode
1460
- next();
1461
- });
1462
- }
1463
-
1464
1622
  // Error handler
1465
1623
  this.app.use((error, req, res, next) => {
1466
1624
  // Ignore request aborted errors - they're harmless (client cancelled request)
@@ -1677,48 +1835,29 @@ class DevServer {
1677
1835
  const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1678
1836
  context.products = productsResponse.products || productsResponse.data?.products || [];
1679
1837
 
1680
- // Ensure all products have proper URL, images, and stock fields
1681
- context.products = context.products.map((product, index) => {
1682
- // Ensure URL field exists
1683
- if (!product.url && !product.link) {
1684
- const handle = product.handle || product.slug || product.id;
1685
- product.url = `/products/${handle}`;
1686
- product.link = `/products/${handle}`;
1687
-
1688
- // Debug first few products to verify URL generation
1689
- if (index < 3) {
1690
- console.log(`[CONTEXT] Product ${index + 1} URL: ${product.url} (handle: ${product.handle || 'none'}, slug: ${product.slug || 'none'}, id: ${product.id || 'none'})`);
1691
- }
1692
- }
1693
- // Ensure stock fields exist (they should from mock-data, but double-check)
1694
- if (product.stock === undefined && product.stockQuantity === undefined) {
1695
- // Calculate from variants if available
1696
- if (product.variants && product.variants.length > 0) {
1697
- const totalStock = product.variants.reduce((sum, v) => sum + (v.stock || 0), 0);
1698
- product.stock = totalStock;
1699
- product.stockQuantity = totalStock;
1700
- product.inStock = totalStock > 0;
1701
- product.available = totalStock > 0;
1702
- } else {
1703
- // Default to in stock if no stock info
1704
- product.stock = 10;
1705
- product.stockQuantity = 10;
1706
- product.inStock = true;
1707
- product.available = true;
1708
- }
1709
- }
1710
- // Ensure image fields exist (they should from mock-data, but double-check)
1711
- if (!product.thumbnailImage1 && !product.imageUrl && (!product.images || product.images.length === 0)) {
1712
- // Fallback to picsum placeholder
1713
- const imageId = product.id ? String(product.id).replace(/\D/g, '') || '1' : '1';
1714
- product.imageUrl = `https://picsum.photos/seed/${imageId}/800/800`;
1715
- product.thumbnailImage1 = {
1716
- url: product.imageUrl,
1717
- altText: product.title || product.name || 'Product image'
1718
- };
1719
- }
1720
- return product;
1721
- });
1838
+ // Enrich all products using the reusable enrichment function
1839
+ // This ensures consistency with products used in widgets and pages
1840
+ context.products = this.enrichProductsData(context.products);
1841
+
1842
+ // Debug first few products to verify enrichment
1843
+ if (context.products.length > 0) {
1844
+ context.products.slice(0, 3).forEach((product, index) => {
1845
+ console.log(`[CONTEXT] Product ${index + 1} after enrichment:`, {
1846
+ id: product.id,
1847
+ handle: product.handle || 'none',
1848
+ url: product.url || product.link || 'MISSING',
1849
+ title: product.title || product.name || 'MISSING',
1850
+ price: product.price || product.sellingPrice || 'MISSING',
1851
+ hasPrices: !!product.prices,
1852
+ stock: product.stock !== undefined ? product.stock : 'MISSING',
1853
+ stockQuantity: product.stockQuantity !== undefined ? product.stockQuantity : 'MISSING',
1854
+ inStock: product.inStock !== undefined ? product.inStock : 'MISSING',
1855
+ available: product.available !== undefined ? product.available : 'MISSING',
1856
+ hasImage: !!(product.imageUrl || product.thumbnailImage1),
1857
+ imageUrl: product.imageUrl || product.thumbnailImage1?.url || 'MISSING'
1858
+ });
1859
+ });
1860
+ }
1722
1861
 
1723
1862
  console.log(`[CONTEXT] Loaded ${context.products.length} products`);
1724
1863
 
@@ -1746,6 +1885,18 @@ class DevServer {
1746
1885
  try {
1747
1886
  const categoriesResponse = await this.apiClient.getCategories({ limit: 50, offset: 0 });
1748
1887
  context.categories = categoriesResponse.categories || categoriesResponse.data?.categories || categoriesResponse || [];
1888
+
1889
+ // Ensure all categories have proper URL fields for navigation
1890
+ context.categories = context.categories.map(category => {
1891
+ // Set URL to category handle (e.g., /accessories)
1892
+ if (!category.url && !category.link) {
1893
+ const handle = category.handle || category.name?.toLowerCase().replace(/\s+/g, '-') || category.id;
1894
+ category.url = `/${handle}`;
1895
+ category.link = `/${handle}`;
1896
+ }
1897
+ return category;
1898
+ });
1899
+
1749
1900
  context.collections = context.categories; // Also set collections for compatibility
1750
1901
  console.log(`[CONTEXT] Loaded ${context.categories.length} categories`);
1751
1902
  } catch (error) {
@@ -1789,6 +1940,14 @@ class DevServer {
1789
1940
  console.error('[CONTEXT] Error details:', error.response?.status, error.response?.data);
1790
1941
  context.menus = [];
1791
1942
  }
1943
+
1944
+ // Set mainMenu for mega-menu fallback (header when no HeaderMenu widget)
1945
+ let mainMenu = null;
1946
+ if (context.menus && context.menus.length > 0) {
1947
+ const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
1948
+ mainMenu = main || context.menus[0];
1949
+ }
1950
+ context.mainMenu = mainMenu;
1792
1951
 
1793
1952
  // Load cart data
1794
1953
  try {
@@ -1885,59 +2044,164 @@ class DevServer {
1885
2044
  content: []
1886
2045
  };
1887
2046
  }
1888
- } else if (this.apiClient) {
1889
- // Use real API
2047
+ } else if (this.mode === 'real') {
2048
+ // Real mode: use webstoreapi-fetcher only (no apiClient). Thin HTTP calls to /webstoreapi/*.
2049
+ const opts = { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} };
2050
+ const baseHost = (process.env.O2VEND_API_BASE_URL || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '') || 'localhost';
2051
+ const themeSettings = context.settings || {};
2052
+ let storeInfo = null;
1890
2053
  try {
1891
- const storeInfo = await this.apiClient.getStoreInfo(true);
1892
- context.shop = storeInfo;
1893
- context.tenant = { id: this.apiClient.tenantId };
1894
-
1895
- // Get widgets for sections
1896
- const widgets = await this.widgetService.getWidgetsBySections();
1897
- context.widgets = widgets;
1898
-
1899
- // Ensure all widgets have template_path set for rendering
1900
- Object.keys(widgets).forEach(section => {
1901
- if (Array.isArray(widgets[section])) {
1902
- widgets[section].forEach(widget => {
1903
- if (!widget.template_path && widget.template) {
1904
- widget.template_path = `widgets/${widget.template}`;
1905
- }
1906
- if (!widget.template_path && widget.type) {
1907
- const templateSlug = this.widgetService.getTemplateSlug(widget.type);
1908
- widget.template_path = `widgets/${templateSlug}`;
1909
- widget.template = templateSlug;
2054
+ storeInfo = await webstoreApi.fetchStoreInfo();
2055
+ } catch (e) {
2056
+ /* use fallbacks */
2057
+ }
2058
+ const logo = (storeInfo && storeInfo.logoUrl) ? storeInfo.logoUrl : '/assets/logo.png';
2059
+ const favicon = (storeInfo && storeInfo.favouriteIconUrl) ? storeInfo.favouriteIconUrl : '/favicon.ico';
2060
+ const storeName = (storeInfo && storeInfo.name) ? storeInfo.name : 'Store';
2061
+ const storeSettings = (storeInfo && storeInfo.settings) || {};
2062
+ try {
2063
+ context.shop = {
2064
+ name: storeName,
2065
+ domain: baseHost,
2066
+ description: 'O2VEND Store',
2067
+ email: storeSettings.companyEmail || 'store@example.com',
2068
+ logo,
2069
+ favicon,
2070
+ currency: storeSettings.currency || themeSettings.currency || 'USD',
2071
+ language: storeSettings.language || themeSettings.language || 'en',
2072
+ locale: themeSettings.locale || 'en-US',
2073
+ settings: {
2074
+ currency: storeSettings.currency || themeSettings.currency || 'USD',
2075
+ currencySymbol: storeSettings.currencySymbol || storeSettings.currency_symbol || themeSettings.currency_symbol || themeSettings.currencySymbol || '$',
2076
+ logo,
2077
+ favicon
2078
+ }
2079
+ };
2080
+ context.tenant = { id: process.env.O2VEND_TENANT_ID || 'real' };
2081
+
2082
+ if (process.env.O2VEND_LAYOUT_FULL === '1' || process.env.O2VEND_LAYOUT_FULL === 'true') {
2083
+ context.settings = context.settings || {};
2084
+ context.settings.layout_style = 'full-width';
2085
+ }
2086
+
2087
+ const page = pageType === 'home' ? 'home' : (pageType === 'products' ? 'products' : 'home');
2088
+ context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
2089
+ context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
2090
+
2091
+ if (this.widgetService) {
2092
+ Object.keys(context.widgets).forEach((section) => {
2093
+ if (Array.isArray(context.widgets[section])) {
2094
+ context.widgets[section].forEach((widget) => {
2095
+ if (!widget.template_path && widget.template) widget.template_path = `widgets/${widget.template}`;
2096
+ if (!widget.template_path && widget.type) {
2097
+ const slug = this.widgetService.getTemplateSlug(widget.type);
2098
+ widget.template_path = `widgets/${slug}`;
2099
+ widget.template = slug;
2100
+ }
2101
+ });
2102
+ }
2103
+ });
2104
+ }
2105
+
2106
+ const productsRes = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, opts);
2107
+ context.products = productsRes?.products || productsRes?.Products || [];
2108
+ context.products = this.normalizeProductsForRealMode(context.products);
2109
+
2110
+ try {
2111
+ const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
2112
+ const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
2113
+ context.categories = raw.map((c) => {
2114
+ const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().replace(/^\s+|\s+$/g, '') || null;
2115
+ if (h) {
2116
+ c.handle = c.handle || h;
2117
+ c.slug = c.slug || h;
2118
+ c.url = `/${h}`;
2119
+ c.link = `/${h}`;
2120
+ }
2121
+ return c;
2122
+ });
2123
+ context.collections = context.categories;
2124
+ } catch (e) {
2125
+ context.categories = [];
2126
+ context.collections = [];
2127
+ }
2128
+
2129
+ try {
2130
+ const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
2131
+ context.brands = brandRes?.brands || brandRes?.data?.brands || brandRes || [];
2132
+ } catch (e) {
2133
+ context.brands = [];
2134
+ }
2135
+
2136
+ try {
2137
+ context.menus = await webstoreApi.fetchMenus(opts);
2138
+ context.menus = Array.isArray(context.menus) ? context.menus : [];
2139
+ let mainMenu = null;
2140
+ if (context.menus.length > 0) {
2141
+ const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
2142
+ const candidate = main || context.menus[0];
2143
+ if (candidate && candidate.id) {
2144
+ try {
2145
+ mainMenu = await webstoreApi.fetchMenuById(candidate.id, opts);
2146
+ } catch (e) {
2147
+ /* ignore */
1910
2148
  }
1911
- });
2149
+ }
1912
2150
  }
1913
- });
1914
-
1915
- // Load page-specific data
1916
- if (pageType === 'products' || pageType === 'home') {
1917
- // Load products for products listing page or homepage
1918
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1919
- context.products = productsResponse.products || [];
1920
- // For products page, also set collection.products
1921
- if (pageType === 'products') {
1922
- context.collection = context.collection || {};
1923
- context.collection.products = context.products;
1924
- context.collection.title = 'All Products';
1925
- context.collection.handle = 'all';
1926
- context.collection.totalProducts = context.products.length;
2151
+ context.mainMenu = mainMenu;
2152
+ } catch (e) {
2153
+ context.menus = [];
2154
+ context.mainMenu = null;
2155
+ }
2156
+
2157
+ try {
2158
+ const cartRes = await webstoreApi.fetchCart(opts);
2159
+ const data = cartRes?.data || cartRes;
2160
+ if (data) {
2161
+ context.cart = {
2162
+ ...context.cart,
2163
+ ...data,
2164
+ itemCount: data.items?.length ?? data.itemCount ?? 0,
2165
+ total: data.total ?? data.subTotal ?? 0
2166
+ };
1927
2167
  }
1928
- } else if (pageType === 'product' && extra.productHandle) {
1929
- // Load product data
1930
- // context.product = await this.loadProduct(extra.productHandle);
2168
+ } catch (e) {
2169
+ /* cart optional */
2170
+ }
2171
+
2172
+ if (pageType === 'products') {
2173
+ context.collection = context.collection || {};
2174
+ context.collection.products = context.products;
2175
+ context.collection.title = 'All Products';
2176
+ context.collection.handle = 'all';
2177
+ context.collection.totalProducts = context.products.length;
1931
2178
  } else if (pageType === 'collection') {
1932
- // Load products for collection
1933
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1934
- context.products = productsResponse.products || [];
1935
- // Set collection.products for collection pages
1936
2179
  context.collection = context.collection || {};
1937
2180
  context.collection.products = context.products;
2181
+ } else if (pageType === 'product' && extra.productHandle) {
2182
+ const handle = String(extra.productHandle).toLowerCase().trim();
2183
+ let product = context.products.find((p) => {
2184
+ const h = (p.handle || '').toLowerCase().trim();
2185
+ const s = (p.slug || '').toLowerCase().trim();
2186
+ const id = String(p.id || '').toLowerCase().trim();
2187
+ const pid = String(p.productId || '').toLowerCase().trim();
2188
+ if (h === handle || s === handle || id === handle) return true;
2189
+ if (handle.startsWith('product-') && (pid === handle.replace(/^product-/, '') || id === handle.replace(/^product-/, ''))) return true;
2190
+ return false;
2191
+ });
2192
+ if (!product) {
2193
+ try {
2194
+ const idToFetch = handle.startsWith('product-') ? handle.replace(/^product-/, '') : handle;
2195
+ product = await webstoreApi.fetchProductById(idToFetch, opts);
2196
+ if (product) product = this.normalizeProductForRealMode(product);
2197
+ } catch (e) {
2198
+ /* ignore */
2199
+ }
2200
+ }
2201
+ if (product) context.product = product;
1938
2202
  }
1939
2203
  } catch (error) {
1940
- console.warn('[CONTEXT] Failed to load from API:', error.message);
2204
+ console.warn('[CONTEXT] Real mode fetch failed:', error.message);
1941
2205
  }
1942
2206
  }
1943
2207
 
@@ -1975,19 +2239,18 @@ class DevServer {
1975
2239
  // Widget templates use: widget.data.products, widget_data.products
1976
2240
  if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
1977
2241
  const widgetProducts = this.getProductsForWidget(widget, products);
1978
-
1979
- // Set products at multiple levels for template compatibility
1980
- widget.data.products = widgetProducts;
1981
- widget.data.Products = widgetProducts;
1982
- widget.products = widgetProducts;
1983
-
1984
- // Also set in content for some template variations
2242
+ const formatted = this.mode === 'real'
2243
+ ? this.normalizeProductsForRealMode(widgetProducts)
2244
+ : this.enrichProductsData(widgetProducts);
2245
+
2246
+ widget.data.products = formatted;
2247
+ widget.data.Products = formatted;
2248
+ widget.products = formatted;
1985
2249
  widget.data.content = widget.data.content || {};
1986
- widget.data.content.products = widgetProducts;
1987
- widget.data.content.Products = widgetProducts;
1988
-
2250
+ widget.data.content.products = formatted;
2251
+ widget.data.content.Products = formatted;
1989
2252
  enrichedCount++;
1990
- console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${widgetProducts.length} products`);
2253
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${formatted.length} products`);
1991
2254
  }
1992
2255
 
1993
2256
  // Enrich CategoryList and CategoryListCarousel widgets with categories
@@ -2024,11 +2287,16 @@ class DevServer {
2024
2287
 
2025
2288
  // Enrich RecentlyViewed widgets with products (use first 6 products as mock recent views)
2026
2289
  if (type === 'recentlyviewed' || type === 'recently-viewed') {
2027
- const recentProducts = products.slice(0, 6);
2290
+ const recent = products.slice(0, 6);
2291
+ const recentProducts = this.mode === 'real'
2292
+ ? this.normalizeProductsForRealMode(recent)
2293
+ : this.enrichProductsData(recent);
2028
2294
  widget.data.products = recentProducts;
2029
2295
  widget.data.Products = recentProducts;
2030
2296
  widget.products = recentProducts;
2031
-
2297
+ widget.data.content = widget.data.content || {};
2298
+ widget.data.content.products = recentProducts;
2299
+ widget.data.content.Products = recentProducts;
2032
2300
  enrichedCount++;
2033
2301
  console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
2034
2302
  }
@@ -2038,37 +2306,322 @@ class DevServer {
2038
2306
  console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
2039
2307
  }
2040
2308
 
2309
+ /**
2310
+ * Check if a product belongs to a category (for collection/category filtering).
2311
+ * Handles categoryId, primaryCategoryId, categoryHandle, and categories[].
2312
+ * @param {Object} p - Product
2313
+ * @param {Object} category - Category { id, handle }
2314
+ * @returns {boolean}
2315
+ */
2316
+ productMatchesCategory(p, category) {
2317
+ const cid = category.id;
2318
+ const ch = (category.handle || category.slug || '').toLowerCase();
2319
+ if (p.categoryId != null && (p.categoryId === cid || String(p.categoryId).toLowerCase() === String(cid).toLowerCase())) return true;
2320
+ if (p.primaryCategoryId != null && (p.primaryCategoryId === cid || String(p.primaryCategoryId).toLowerCase() === String(cid).toLowerCase())) return true;
2321
+ const ph = (p.categoryHandle || p.category?.handle || '').toLowerCase();
2322
+ if (ch && ph && ph === ch) return true;
2323
+ const arr = p.categories || p.categoryIds || [];
2324
+ if (Array.isArray(arr) && arr.some(cat => {
2325
+ const o = typeof cat === 'object' ? cat : { id: cat };
2326
+ return o.id === cid || (o.handle || o.slug || '').toLowerCase() === ch;
2327
+ })) return true;
2328
+ return false;
2329
+ }
2330
+
2331
+ /**
2332
+ * Enrich a single product with all required data (URL, name/title, price, images, stock)
2333
+ * This ensures consistency between widgets and pages
2334
+ * @param {Object} product - Product object to enrich
2335
+ * @returns {Object} Enriched product (creates a copy to avoid mutation)
2336
+ */
2337
+ enrichProductData(product) {
2338
+ // Create a copy to avoid mutating the original
2339
+ const enriched = { ...product };
2340
+
2341
+ // CRITICAL: Ensure productId exists - it's the most reliable identifier
2342
+ if (!enriched.productId && enriched.id) {
2343
+ // Try to extract numeric ID from string ID (e.g., "product-1" -> 1)
2344
+ const numericMatch = String(enriched.id).match(/\d+/);
2345
+ if (numericMatch) {
2346
+ enriched.productId = parseInt(numericMatch[0], 10);
2347
+ } else {
2348
+ // Use the ID as-is if it's already numeric
2349
+ enriched.productId = enriched.id;
2350
+ }
2351
+ } else if (!enriched.productId && !enriched.id) {
2352
+ console.warn(`[ENRICH] ⚠️ Product missing both id and productId:`, {
2353
+ keys: Object.keys(enriched).slice(0, 15),
2354
+ title: enriched.title || enriched.name
2355
+ });
2356
+ }
2357
+
2358
+ // DEBUG: Log incoming product to diagnose missing fields
2359
+ if (!product.id && !product.handle && !product.slug && !product.productId) {
2360
+ console.warn(`[ENRICH] ⚠️ Product missing all identifiers (id/handle/slug/productId):`, {
2361
+ keys: Object.keys(product).slice(0, 15),
2362
+ title: product.title || product.name
2363
+ });
2364
+ }
2365
+
2366
+ // Ensure URL field exists - CRITICAL for product navigation
2367
+ // Always use /products/{handle} so links target dev server (never external URLs)
2368
+ const handle = enriched.handle || enriched.slug || enriched.id;
2369
+ const effectiveHandle = (handle && String(handle).trim() && String(handle) !== '#')
2370
+ ? String(handle).trim().toLowerCase()
2371
+ : (enriched.productId ? `product-${enriched.productId}`.toLowerCase() : null);
2372
+ if (effectiveHandle) {
2373
+ enriched.url = `/products/${effectiveHandle}`;
2374
+ enriched.link = `/products/${effectiveHandle}`;
2375
+ if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') enriched.handle = effectiveHandle;
2376
+ if (!enriched.slug || enriched.slug === '#' || enriched.slug === '') enriched.slug = effectiveHandle;
2377
+ } else {
2378
+ enriched.url = '#';
2379
+ enriched.link = '#';
2380
+ console.warn(`[ENRICH] ⚠️ Product has no id/handle/slug/productId, URL set to '#'`, {
2381
+ productKeys: Object.keys(enriched).slice(0, 10)
2382
+ });
2383
+ }
2384
+
2385
+ // Ensure handle/slug exist if missing (for template fallback logic)
2386
+ // Also normalize them (remove '#' or empty strings)
2387
+ if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') {
2388
+ if (enriched.slug && enriched.slug !== '#' && enriched.slug !== '') {
2389
+ enriched.handle = enriched.slug;
2390
+ } else if (enriched.id && enriched.id !== '#') {
2391
+ enriched.handle = String(enriched.id).toLowerCase();
2392
+ } else if (enriched.productId) {
2393
+ enriched.handle = `product-${enriched.productId}`.toLowerCase();
2394
+ } else if (!enriched.handle) {
2395
+ // Generate from URL if it was set above
2396
+ if (enriched.url && enriched.url !== '#') {
2397
+ enriched.handle = enriched.url.replace(/^\/products\//, '').toLowerCase();
2398
+ }
2399
+ }
2400
+ }
2401
+
2402
+ if (!enriched.slug || enriched.slug === '#' || enriched.slug === '') {
2403
+ if (enriched.handle && enriched.handle !== '#' && enriched.handle !== '') {
2404
+ enriched.slug = enriched.handle;
2405
+ } else if (enriched.id && enriched.id !== '#') {
2406
+ enriched.slug = String(enriched.id).toLowerCase();
2407
+ } else if (enriched.productId) {
2408
+ enriched.slug = `product-${enriched.productId}`.toLowerCase();
2409
+ } else if (!enriched.slug) {
2410
+ // Generate from URL if it was set above
2411
+ if (enriched.url && enriched.url !== '#') {
2412
+ enriched.slug = enriched.url.replace(/^\/products\//, '').toLowerCase();
2413
+ }
2414
+ }
2415
+ }
2416
+
2417
+ // Ensure name/title exists - CRITICAL for product display
2418
+ if (!enriched.name && !enriched.title) {
2419
+ enriched.name = `Product ${enriched.id || 'Unknown'}`;
2420
+ enriched.title = enriched.name;
2421
+ } else if (!enriched.name) {
2422
+ enriched.name = enriched.title || `Product ${enriched.id || 'Unknown'}`;
2423
+ } else if (!enriched.title) {
2424
+ enriched.title = enriched.name;
2425
+ }
2426
+
2427
+ // Ensure both variations and variants exist (product-card uses variations)
2428
+ if (enriched.variants && !enriched.variations) {
2429
+ enriched.variations = enriched.variants;
2430
+ } else if (enriched.variations && !enriched.variants) {
2431
+ enriched.variants = enriched.variations;
2432
+ }
2433
+
2434
+ // Ensure price exists - CRITICAL for product display
2435
+ if (!enriched.price && !enriched.sellingPrice) {
2436
+ // Try to get from variants/variations first
2437
+ const variantsOrVariations = enriched.variants || enriched.variations;
2438
+ if (variantsOrVariations && variantsOrVariations.length > 0) {
2439
+ enriched.price = variantsOrVariations[0].price || variantsOrVariations[0].sellingPrice || 0;
2440
+ enriched.sellingPrice = enriched.price;
2441
+ } else {
2442
+ enriched.price = 0;
2443
+ enriched.sellingPrice = 0;
2444
+ }
2445
+ } else if (!enriched.price) {
2446
+ enriched.price = enriched.sellingPrice;
2447
+ } else if (!enriched.sellingPrice) {
2448
+ enriched.sellingPrice = enriched.price;
2449
+ }
2450
+
2451
+ // Ensure prices object exists for product-card compatibility - CRITICAL
2452
+ // Make sure price is NOT 0 unless it's actually 0 (check if undefined/null)
2453
+ const finalPrice = (enriched.price !== undefined && enriched.price !== null)
2454
+ ? enriched.price
2455
+ : ((enriched.sellingPrice !== undefined && enriched.sellingPrice !== null)
2456
+ ? enriched.sellingPrice
2457
+ : 0);
2458
+
2459
+ if (!enriched.prices) {
2460
+ const mrpValue = enriched.compareAtPrice || enriched.comparePrice || enriched.mrp || 0;
2461
+ enriched.prices = {
2462
+ price: finalPrice,
2463
+ mrp: mrpValue,
2464
+ currency: enriched.currency || 'USD'
2465
+ };
2466
+ } else {
2467
+ // Update prices object if it exists but is missing fields
2468
+ if (enriched.prices.price === undefined || enriched.prices.price === null) {
2469
+ enriched.prices.price = finalPrice;
2470
+ }
2471
+ if (enriched.prices.mrp === undefined || enriched.prices.mrp === null) {
2472
+ enriched.prices.mrp = enriched.compareAtPrice || enriched.comparePrice || enriched.mrp || 0;
2473
+ }
2474
+ if (!enriched.prices.currency) {
2475
+ enriched.prices.currency = enriched.currency || 'USD';
2476
+ }
2477
+ }
2478
+
2479
+ // Ensure price/sellingPrice fields also exist (not just prices object)
2480
+ if (enriched.price === undefined || enriched.price === null) {
2481
+ enriched.price = finalPrice;
2482
+ }
2483
+ if (enriched.sellingPrice === undefined || enriched.sellingPrice === null) {
2484
+ enriched.sellingPrice = finalPrice;
2485
+ }
2486
+
2487
+ // Ensure image exists - CRITICAL for product display
2488
+ if (!enriched.imageUrl && !enriched.thumbnailImage1 && (!enriched.images || enriched.images.length === 0)) {
2489
+ // Fallback to picsum placeholder
2490
+ const imageId = enriched.id ? String(enriched.id).replace(/\D/g, '') || '1' : '1';
2491
+ enriched.imageUrl = `https://picsum.photos/seed/${imageId}/800/800`;
2492
+ enriched.thumbnailImage1 = {
2493
+ url: enriched.imageUrl,
2494
+ altText: enriched.title || enriched.name || 'Product image'
2495
+ };
2496
+ }
2497
+
2498
+ // Ensure stock/availability data exists - CRITICAL to prevent incorrect "SOLD OUT" display
2499
+ if (enriched.stock === undefined && enriched.stockQuantity === undefined) {
2500
+ // Calculate from variants/variations if available
2501
+ const variantsOrVariations = enriched.variants || enriched.variations;
2502
+ if (variantsOrVariations && variantsOrVariations.length > 0) {
2503
+ const totalStock = variantsOrVariations.reduce((sum, v) => sum + (v.stock || v.quantity || 0), 0);
2504
+ enriched.stock = totalStock;
2505
+ enriched.stockQuantity = totalStock;
2506
+ enriched.inStock = totalStock > 0;
2507
+ enriched.available = totalStock > 0;
2508
+ } else {
2509
+ // Default to in stock if no stock info (prevents false "SOLD OUT" display)
2510
+ enriched.stock = 10;
2511
+ enriched.stockQuantity = 10;
2512
+ enriched.inStock = true;
2513
+ enriched.available = true;
2514
+ }
2515
+ } else {
2516
+ // Ensure inStock/available flags are set based on stock value
2517
+ // CRITICAL: Use nullish coalescing to handle 0 values correctly
2518
+ const stockValue = enriched.stock !== undefined ? enriched.stock : (enriched.stockQuantity !== undefined ? enriched.stockQuantity : 0);
2519
+ enriched.inStock = stockValue > 0;
2520
+ enriched.available = stockValue > 0;
2521
+
2522
+ // Also ensure stockQuantity is set if only stock is set (and vice versa)
2523
+ if (enriched.stock !== undefined && enriched.stockQuantity === undefined) {
2524
+ enriched.stockQuantity = enriched.stock;
2525
+ } else if (enriched.stockQuantity !== undefined && enriched.stock === undefined) {
2526
+ enriched.stock = enriched.stockQuantity;
2527
+ }
2528
+ }
2529
+
2530
+ return enriched;
2531
+ }
2532
+
2533
+ /**
2534
+ * Normalize real-API product for template use (no enrichment).
2535
+ * Aliases API keys (Description, Images, etc.) to template-expected keys,
2536
+ * sets url/link for dev navigation, and normalizes image items to { url, altText }.
2537
+ * @param {Object} p - Product from real API
2538
+ * @returns {Object} Normalized product (shallow copy + aliases)
2539
+ */
2540
+ normalizeProductForRealMode(p) {
2541
+ if (!p || typeof p !== 'object') return p;
2542
+ const n = { ...p };
2543
+ if (n.Description != null && n.description == null) n.description = n.Description;
2544
+ if (n.HtmlContent != null && n.htmlContent == null) n.htmlContent = n.HtmlContent;
2545
+ if (n.ShortDescription != null && n.shortDescription == null) n.shortDescription = n.ShortDescription;
2546
+ if (n.Name != null && n.name == null) n.name = n.Name;
2547
+ if (n.Title != null && n.title == null) n.title = n.Title;
2548
+ if (n.Attributes != null && n.attributes == null) n.attributes = n.Attributes;
2549
+ const t1 = n.ThumbnailImage1 || n.thumbnailImage1;
2550
+ if (t1 != null) {
2551
+ const u = typeof t1 === 'string' ? t1 : (t1.url || t1.Url || t1.imageUrl || t1.ImageUrl);
2552
+ if (u) {
2553
+ const a = typeof t1 === 'string' ? (n.name || n.title) : (t1.altText || t1.AltText || n.name || n.title);
2554
+ n.thumbnailImage1 = { url: u, altText: a };
2555
+ if (!n.thumbnailImage) n.thumbnailImage = n.thumbnailImage1;
2556
+ } else if (!n.thumbnailImage1) n.thumbnailImage1 = t1;
2557
+ }
2558
+ let imgs = n.images || n.Images;
2559
+ if (Array.isArray(imgs) && imgs.length > 0) {
2560
+ n.images = imgs.map((img) => {
2561
+ if (typeof img === 'string') return { url: img, altText: n.name || n.title };
2562
+ const u = img.url || img.Url || img.imageUrl || img.ImageUrl;
2563
+ const a = img.altText || img.AltText || n.name || n.title;
2564
+ return u ? { url: u, altText: a } : (typeof img === 'object' ? { url: img, altText: a } : { url: String(img), altText: a });
2565
+ });
2566
+ } else if (imgs) {
2567
+ n.images = Array.isArray(imgs) ? imgs : [imgs];
2568
+ } else {
2569
+ const single = n.imageUrl || n.ImageUrl;
2570
+ if (single) n.images = [{ url: single, altText: n.name || n.title }];
2571
+ }
2572
+ const handle = (n.handle || n.slug || n.id || '').toString().trim();
2573
+ const h = handle && handle !== '#' ? handle.toLowerCase() : (n.productId ? `product-${n.productId}` : null);
2574
+ if (h) {
2575
+ n.url = `/products/${h}`;
2576
+ n.link = `/products/${h}`;
2577
+ }
2578
+ return n;
2579
+ }
2580
+
2581
+ normalizeProductsForRealMode(products) {
2582
+ if (!Array.isArray(products)) return [];
2583
+ return products.map((p) => this.normalizeProductForRealMode(p));
2584
+ }
2585
+
2586
+ /**
2587
+ * Enrich multiple products with all required data
2588
+ * @param {Array} products - Array of products to enrich
2589
+ * @returns {Array} Array of enriched products
2590
+ */
2591
+ enrichProductsData(products) {
2592
+ if (!Array.isArray(products)) return [];
2593
+ return products.map(product => this.enrichProductData(product));
2594
+ }
2595
+
2041
2596
  /**
2042
2597
  * Get products for a specific widget based on its settings
2043
2598
  */
2044
2599
  getProductsForWidget(widget, products) {
2045
2600
  // Get the limit from widget settings, default to 12
2046
- const limit = widget.settings?.limit || widget.data?.content?.Limit || 12;
2601
+ const limit = widget.settings?.limit || widget.data?.content?.Limit || widget.settings?.numberOfProducts || widget.settings?.NumberOfProducts || 12;
2047
2602
 
2048
2603
  // Get products based on widget type
2049
2604
  const type = (widget.type || '').toLowerCase();
2050
2605
 
2606
+ let widgetProducts = [];
2607
+
2051
2608
  if (type.includes('featured') || type === 'featuredproducts') {
2052
2609
  // Featured products - filter by featured tag or just take first N
2053
2610
  const featured = products.filter(p => p.tags?.includes('featured') || p.featured);
2054
- return featured.length > 0 ? featured.slice(0, limit) : products.slice(0, limit);
2055
- }
2056
-
2057
- if (type.includes('bestseller') || type === 'bestsellerproducts') {
2611
+ widgetProducts = featured.length > 0 ? featured.slice(0, limit) : products.slice(0, limit);
2612
+ } else if (type.includes('bestseller') || type === 'bestsellerproducts') {
2058
2613
  // Best seller products - sort by sales or just take random N
2059
- return products.slice(0, limit);
2060
- }
2061
-
2062
- if (type.includes('new') || type === 'newproducts') {
2614
+ widgetProducts = products.slice(0, limit);
2615
+ } else if (type.includes('new') || type === 'newproducts') {
2063
2616
  // New products - sort by created date
2064
2617
  const sorted = [...products].sort((a, b) =>
2065
2618
  new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
2066
2619
  );
2067
- return sorted.slice(0, limit);
2620
+ widgetProducts = sorted.slice(0, limit);
2621
+ } else {
2622
+ widgetProducts = products.slice(0, limit);
2068
2623
  }
2069
-
2070
- // Default: return first N products
2071
- return products.slice(0, limit);
2624
+ return widgetProducts;
2072
2625
  }
2073
2626
 
2074
2627
  /**
@@ -2873,6 +3426,22 @@ class DevServer {
2873
3426
  logLevel: 'silent',
2874
3427
  timeout: 2000
2875
3428
  }));
3429
+ } else if (this.mode === 'real') {
3430
+ const base = (process.env.O2VEND_API_BASE_URL || '').replace(/\/$/, '').replace(/\/shopfront\/api\/v2\/?$/i, '').replace(/\/webstoreapi\/?$/i, '');
3431
+ if (base) {
3432
+ const { createProxyMiddleware } = require('http-proxy-middleware');
3433
+ const proxyOpts = {
3434
+ target: base,
3435
+ changeOrigin: true,
3436
+ logLevel: 'silent',
3437
+ timeout: parseInt(process.env.O2VEND_API_TIMEOUT, 10) || 10000,
3438
+ onProxyReq: (proxyReq, req) => {
3439
+ if (req.headers.cookie) proxyReq.setHeader('Cookie', req.headers.cookie);
3440
+ }
3441
+ };
3442
+ this.app.use('/webstoreapi', createProxyMiddleware(proxyOpts));
3443
+ this.app.use('/shopfront/api', createProxyMiddleware(proxyOpts));
3444
+ }
2876
3445
  }
2877
3446
 
2878
3447
  // Inject hot reload script into HTML responses