@o2vend/theme-cli 1.0.34 → 1.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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');
@@ -471,22 +489,22 @@ 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 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
504
+ const filtered = context.products.filter(p => this.productMatchesCategory(p, category));
505
+ const filteredProducts = this.mode === 'real'
506
+ ? filtered
507
+ : filtered.map(p => this.enrichProductData(p));
490
508
 
491
509
  // CRITICAL: Set both context.products and context.collection.products to the SAME enriched array
492
510
  // This ensures they always reference the same objects (like widgets do)
@@ -507,14 +525,11 @@ class DevServer {
507
525
  }
508
526
  }
509
527
 
510
- // 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}`);
528
+ if (context.products && context.products.length > 0 && this.mode !== 'real') {
529
+ if (!context.products[0].url && !context.products[0].link) {
530
+ console.log(`[PRODUCTS PAGE] Products need enrichment - enriching now...`);
531
+ context.products = this.enrichProductsData(context.products);
532
+ }
518
533
  }
519
534
 
520
535
  // Debug: Log sample product data to verify enrichment
@@ -543,23 +558,26 @@ class DevServer {
543
558
  if (!context.products || context.products.length === 0) {
544
559
  console.warn('[PRODUCTS PAGE] ⚠️ No products in context, attempting to reload...');
545
560
  try {
546
- const queryParams = {};
561
+ const queryParams = { limit: 50, offset: 0 };
547
562
  if (categoryFilter) {
548
- // 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
- }
563
+ const cf = (categoryFilter || '').toLowerCase();
564
+ const cat = context.categories?.find(c => {
565
+ const ch = (c.handle || '').toLowerCase();
566
+ const cs = (c.slug || '').toLowerCase();
567
+ const cn = (c.name || '').toLowerCase();
568
+ const cnNorm = (c.name || '').toLowerCase().replace(/\s+/g, '-');
569
+ return ch === cf || cs === cf || cn === cf || cnNorm === cf;
570
+ });
571
+ if (cat) queryParams.categoryId = cat.id;
572
+ }
573
+ if (this.mode === 'real') {
574
+ const res = await webstoreApi.fetchProducts(queryParams, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
575
+ context.products = this.normalizeProductsForRealMode(res?.products || res?.Products || []);
576
+ } else if (this.apiClient) {
577
+ const productsResponse = await this.apiClient.getProducts(queryParams);
578
+ context.products = productsResponse.products || productsResponse.data?.products || [];
579
+ context.products = this.enrichProductsData(context.products);
556
580
  }
557
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0, ...queryParams });
558
- context.products = productsResponse.products || productsResponse.data?.products || [];
559
-
560
- // Ensure URLs and all product data are set
561
- context.products = this.enrichProductsData(context.products);
562
-
563
581
  console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.products.length} products`);
564
582
  if (context.products.length > 0) {
565
583
  const sample = context.products[0];
@@ -598,12 +616,15 @@ class DevServer {
598
616
  if (!context.widgets || Object.keys(context.widgets).length === 0) {
599
617
  console.warn('[PRODUCTS PAGE] ⚠️ No widgets in context, attempting to reload...');
600
618
  try {
601
- if (this.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) {
602
624
  const widgets = await this.widgetService.getWidgetsBySections();
603
625
  context.widgets = widgets || { hero: [], content: [], footer: [], header: [] };
604
626
  console.log(`[PRODUCTS PAGE] ✅ Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
605
627
  } else {
606
- console.error('[PRODUCTS PAGE] ❌ Widget service not available');
607
628
  context.widgets = { hero: [], content: [], footer: [], header: [] };
608
629
  }
609
630
  } catch (error) {
@@ -611,16 +632,22 @@ class DevServer {
611
632
  context.widgets = { hero: [], content: [], footer: [], header: [] };
612
633
  }
613
634
  }
614
-
635
+
615
636
  // Ensure menus are available
616
637
  if (!context.menus || context.menus.length === 0) {
617
638
  console.warn('[PRODUCTS PAGE] ⚠️ No menus in context, attempting to reload...');
618
639
  try {
619
- 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`);
640
+ if (this.mode === 'real') {
641
+ context.menus = await webstoreApi.fetchMenus({ headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
642
+ context.menus = Array.isArray(context.menus) ? context.menus : [];
643
+ console.log(`[PRODUCTS PAGE] Reloaded ${context.menus.length} menus (real)`);
644
+ } else {
645
+ const axios = require('axios');
646
+ const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
647
+ const menusData = menusResponse.data;
648
+ context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
649
+ console.log(`[PRODUCTS PAGE] ✅ Reloaded ${context.menus.length} menus`);
650
+ }
624
651
  } catch (error) {
625
652
  console.error('[PRODUCTS PAGE] ❌ Failed to reload menus:', error.message);
626
653
  context.menus = [];
@@ -873,31 +900,24 @@ class DevServer {
873
900
  this.app.get('/collections/:handle', async (req, res, next) => {
874
901
  try {
875
902
  const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
876
- // 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
- );
903
+ const h = (req.params.handle || '').toLowerCase();
904
+ const category = context.categories?.find(c => {
905
+ const ch = (c.handle || '').toLowerCase();
906
+ const cs = (c.slug || '').toLowerCase();
907
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
908
+ return ch === h || cs === h || cn === h;
909
+ });
881
910
  if (category) {
882
911
  context.category = category;
883
912
  context.collection = category;
884
-
885
- // 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
-
913
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
914
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
893
915
  context.collection.products = filteredProducts;
894
916
  context.collection.totalProducts = filteredProducts.length;
895
- context.products = filteredProducts; // Also set in products array for consistency
896
-
917
+ context.products = filteredProducts;
897
918
  console.log(`[COLLECTION PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
898
919
  } else {
899
- // No category found, use all products with enrichment
900
- context.products = this.enrichProductsData(context.products || []);
920
+ if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
901
921
  context.collection = context.collection || {};
902
922
  context.collection.products = context.products;
903
923
  }
@@ -956,30 +976,24 @@ class DevServer {
956
976
  this.app.get('/categories/:handle', async (req, res, next) => {
957
977
  try {
958
978
  const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
959
- const category = context.categories?.find(c =>
960
- c.handle === req.params.handle ||
961
- c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
962
- );
979
+ const h = (req.params.handle || '').toLowerCase();
980
+ const category = context.categories?.find(c => {
981
+ const ch = (c.handle || '').toLowerCase();
982
+ const cs = (c.slug || '').toLowerCase();
983
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
984
+ return ch === h || cs === h || cn === h;
985
+ });
963
986
  if (category) {
964
987
  context.category = category;
965
988
  context.collection = category;
966
-
967
- // 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
-
989
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
990
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
975
991
  context.collection.products = filteredProducts;
976
992
  context.collection.totalProducts = filteredProducts.length;
977
- context.products = filteredProducts; // Also set in products array for consistency
978
-
993
+ context.products = filteredProducts;
979
994
  console.log(`[CATEGORY PAGE] Category: ${category.name}, Products: ${filteredProducts.length}`);
980
995
  } else {
981
- // No category found, use all products with enrichment
982
- context.products = this.enrichProductsData(context.products || []);
996
+ if (this.mode !== 'real') context.products = this.enrichProductsData(context.products || []);
983
997
  context.collection = context.collection || {};
984
998
  context.collection.products = context.products;
985
999
  }
@@ -1107,41 +1121,38 @@ class DevServer {
1107
1121
  try {
1108
1122
  const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
1109
1123
 
1110
- // Load page content from shopfront API (mock)
1124
+ // Load page content
1111
1125
  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) {
1126
+ if (this.mode === 'real') {
1127
+ const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
1128
+ if (pageData) {
1129
1129
  context.page = {
1130
1130
  ...context.page,
1131
- ...pageResponse.data,
1132
- body_html: pageResponse.data.content,
1133
- content: pageResponse.data.content
1131
+ ...pageData,
1132
+ body_html: pageData.content || pageData.body_html,
1133
+ content: pageData.content || pageData.body_html
1134
1134
  };
1135
1135
  }
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
- };
1136
+ } else {
1137
+ const axios = require('axios');
1138
+ try {
1139
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
1140
+ if (pageResponse.data) {
1141
+ context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content || pageResponse.data.htmlContent, content: pageResponse.data.content || pageResponse.data.htmlContent };
1142
+ }
1143
+ } catch (e) {
1144
+ const fallback = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1145
+ if (fallback.data) {
1146
+ context.page = { ...context.page, ...fallback.data, body_html: fallback.data.content, content: fallback.data.content };
1147
+ }
1148
+ }
1144
1149
  }
1150
+ } catch (error) {
1151
+ console.warn(`[PAGE] Failed to load page for ${req.params.handle}:`, error.message);
1152
+ }
1153
+ if (!context.page.body_html && !context.page.content) {
1154
+ const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
1155
+ context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Landing page content coming soon.</p>`, content: `<h2>${t}</h2><p>Landing page content coming soon.</p>` };
1145
1156
  }
1146
1157
 
1147
1158
  // Try page.liquid template first, then fallback to rendering raw HTML
@@ -1168,30 +1179,27 @@ class DevServer {
1168
1179
  try {
1169
1180
  const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
1170
1181
 
1171
- // Load page content from mock API
1172
1182
  try {
1173
- 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
- };
1183
+ if (this.mode === 'real') {
1184
+ const pageData = await webstoreApi.fetchPage(req.params.handle, { headers: req.headers.cookie ? { Cookie: req.headers.cookie } : {} });
1185
+ if (pageData) {
1186
+ context.page = { ...context.page, ...pageData, body_html: pageData.content || pageData.body_html, content: pageData.content || pageData.body_html };
1187
+ }
1188
+ } else {
1189
+ const axios = require('axios');
1190
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
1191
+ if (pageResponse.data) {
1192
+ context.page = { ...context.page, ...pageResponse.data, body_html: pageResponse.data.content, content: pageResponse.data.content };
1193
+ }
1182
1194
  }
1183
1195
  } catch (error) {
1184
1196
  console.warn(`[PAGE] Failed to load page content for ${req.params.handle}:`, error.message);
1185
- // Set default page content
1186
- context.page = {
1187
- ...context.page,
1188
- title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
1189
- handle: req.params.handle,
1190
- body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`,
1191
- content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`
1192
- };
1193
1197
  }
1194
-
1198
+ if (!context.page.body_html && !context.page.content) {
1199
+ const t = req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ');
1200
+ context.page = { ...context.page, title: t, handle: req.params.handle, body_html: `<h2>${t}</h2><p>Page content coming soon.</p>`, content: `<h2>${t}</h2><p>Page content coming soon.</p>` };
1201
+ }
1202
+
1195
1203
  const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
1196
1204
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1197
1205
  res.setHeader('Cache-Control', 'no-cache');
@@ -1249,10 +1257,13 @@ class DevServer {
1249
1257
  const widgetType = req.params.type.replace(/\.liquid$/, '');
1250
1258
  const context = await this.buildContext(req, 'home');
1251
1259
 
1252
- // Ensure products are loaded for widget context (needed for product widgets)
1253
1260
  if (!context.products || context.products.length === 0) {
1254
1261
  try {
1255
- if (this.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) {
1256
1267
  const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1257
1268
  context.products = productsResponse.products || productsResponse.data?.products || [];
1258
1269
  console.log(`[DEV WIDGET] Loaded ${context.products.length} products for widget context`);
@@ -1408,32 +1419,22 @@ class DevServer {
1408
1419
  // Build context to get categories, brands, and products
1409
1420
  const context = await this.buildContext(req, 'collection', { collectionHandle: handle });
1410
1421
 
1411
- // Try to find category by handle (case-insensitive)
1412
- const 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
- );
1422
+ // Try to find category by handle or slug (case-insensitive)
1423
+ const h = handle.toLowerCase();
1424
+ const category = context.categories?.find(c => {
1425
+ const ch = (c.handle || '').toLowerCase();
1426
+ const cs = (c.slug || '').toLowerCase();
1427
+ const cn = (c.name || '').toLowerCase().replace(/\s+/g, '-');
1428
+ return ch === h || cs === h || cn === h;
1429
+ });
1416
1430
 
1417
1431
  if (category) {
1418
- // Found a category - render products list (NOT categories list)
1419
1432
  context.category = category;
1420
1433
  context.collection = category;
1421
-
1422
1434
  console.log(`[ROOT COLLECTION] Found category: ${category.name} (ID: ${category.id})`);
1423
1435
  console.log(`[ROOT COLLECTION] Total products in context: ${context.products?.length || 0}`);
1424
-
1425
- // 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
-
1436
+ const filtered = (context.products || []).filter(p => this.productMatchesCategory(p, category));
1437
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
1437
1438
  console.log(`[ROOT COLLECTION] Filtered products count: ${filteredProducts.length}`);
1438
1439
 
1439
1440
  // Log sample enriched products
@@ -1536,17 +1537,13 @@ class DevServer {
1536
1537
  );
1537
1538
 
1538
1539
  if (brand) {
1539
- // Found a brand - render products list
1540
1540
  context.brand = brand;
1541
-
1542
- // 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));
1541
+ const filtered = (context.products || []).filter(p =>
1542
+ p.brandId === brand.id ||
1543
+ String(p.brandId).toLowerCase() === String(brand.id).toLowerCase() ||
1544
+ (p.brand && p.brand.toLowerCase() === brand.name.toLowerCase())
1545
+ );
1546
+ const filteredProducts = this.mode === 'real' ? filtered : filtered.map(p => this.enrichProductData(p));
1550
1547
 
1551
1548
  context.brand.products = filteredProducts;
1552
1549
  context.products = filteredProducts;
@@ -1612,20 +1609,16 @@ class DevServer {
1612
1609
  }
1613
1610
  });
1614
1611
 
1615
- // Favicon handler
1612
+ // Favicon handler: serve from theme assets if present, else 204
1616
1613
  this.app.get('/favicon.ico', (req, res) => {
1617
- 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();
1618
1620
  });
1619
1621
 
1620
- // API proxy (for real API mode)
1621
- if (this.mode === 'real' && this.apiClient) {
1622
- this.app.use('/api', (req, res, next) => {
1623
- // Proxy API requests to real API
1624
- // This is handled by mock API in mock mode
1625
- next();
1626
- });
1627
- }
1628
-
1629
1622
  // Error handler
1630
1623
  this.app.use((error, req, res, next) => {
1631
1624
  // Ignore request aborted errors - they're harmless (client cancelled request)
@@ -1947,6 +1940,14 @@ class DevServer {
1947
1940
  console.error('[CONTEXT] Error details:', error.response?.status, error.response?.data);
1948
1941
  context.menus = [];
1949
1942
  }
1943
+
1944
+ // Set mainMenu for mega-menu fallback (header when no HeaderMenu widget)
1945
+ let mainMenu = null;
1946
+ if (context.menus && context.menus.length > 0) {
1947
+ const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
1948
+ mainMenu = main || context.menus[0];
1949
+ }
1950
+ context.mainMenu = mainMenu;
1950
1951
 
1951
1952
  // Load cart data
1952
1953
  try {
@@ -2043,59 +2044,164 @@ class DevServer {
2043
2044
  content: []
2044
2045
  };
2045
2046
  }
2046
- } else if (this.apiClient) {
2047
- // 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;
2048
2053
  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;
2054
+ storeInfo = await webstoreApi.fetchStoreInfo();
2055
+ } catch (e) {
2056
+ /* use fallbacks */
2057
+ }
2058
+ const logo = (storeInfo && storeInfo.logoUrl) ? storeInfo.logoUrl : '/assets/logo.png';
2059
+ const favicon = (storeInfo && storeInfo.favouriteIconUrl) ? storeInfo.favouriteIconUrl : '/favicon.ico';
2060
+ const storeName = (storeInfo && storeInfo.name) ? storeInfo.name : 'Store';
2061
+ const storeSettings = (storeInfo && storeInfo.settings) || {};
2062
+ try {
2063
+ context.shop = {
2064
+ name: storeName,
2065
+ domain: baseHost,
2066
+ description: 'O2VEND Store',
2067
+ email: storeSettings.companyEmail || 'store@example.com',
2068
+ logo,
2069
+ favicon,
2070
+ currency: storeSettings.currency || themeSettings.currency || 'USD',
2071
+ language: storeSettings.language || themeSettings.language || 'en',
2072
+ locale: themeSettings.locale || 'en-US',
2073
+ settings: {
2074
+ currency: storeSettings.currency || themeSettings.currency || 'USD',
2075
+ currencySymbol: storeSettings.currencySymbol || storeSettings.currency_symbol || themeSettings.currency_symbol || themeSettings.currencySymbol || '$',
2076
+ logo,
2077
+ favicon
2078
+ }
2079
+ };
2080
+ context.tenant = { id: process.env.O2VEND_TENANT_ID || 'real' };
2081
+
2082
+ if (process.env.O2VEND_LAYOUT_FULL === '1' || process.env.O2VEND_LAYOUT_FULL === 'true') {
2083
+ context.settings = context.settings || {};
2084
+ context.settings.layout_style = 'full-width';
2085
+ }
2086
+
2087
+ const page = pageType === 'home' ? 'home' : (pageType === 'products' ? 'products' : 'home');
2088
+ context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
2089
+ context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
2090
+
2091
+ if (this.widgetService) {
2092
+ Object.keys(context.widgets).forEach((section) => {
2093
+ if (Array.isArray(context.widgets[section])) {
2094
+ context.widgets[section].forEach((widget) => {
2095
+ if (!widget.template_path && widget.template) widget.template_path = `widgets/${widget.template}`;
2096
+ if (!widget.template_path && widget.type) {
2097
+ const slug = this.widgetService.getTemplateSlug(widget.type);
2098
+ widget.template_path = `widgets/${slug}`;
2099
+ widget.template = slug;
2100
+ }
2101
+ });
2102
+ }
2103
+ });
2104
+ }
2105
+
2106
+ const productsRes = await webstoreApi.fetchProducts({ limit: 50, offset: 0 }, opts);
2107
+ context.products = productsRes?.products || productsRes?.Products || [];
2108
+ context.products = this.normalizeProductsForRealMode(context.products);
2109
+
2110
+ try {
2111
+ const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
2112
+ const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
2113
+ context.categories = raw.map((c) => {
2114
+ const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().replace(/^\s+|\s+$/g, '') || null;
2115
+ if (h) {
2116
+ c.handle = c.handle || h;
2117
+ c.slug = c.slug || h;
2118
+ c.url = `/${h}`;
2119
+ c.link = `/${h}`;
2120
+ }
2121
+ return c;
2122
+ });
2123
+ context.collections = context.categories;
2124
+ } catch (e) {
2125
+ context.categories = [];
2126
+ context.collections = [];
2127
+ }
2128
+
2129
+ try {
2130
+ const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
2131
+ context.brands = brandRes?.brands || brandRes?.data?.brands || brandRes || [];
2132
+ } catch (e) {
2133
+ context.brands = [];
2134
+ }
2135
+
2136
+ try {
2137
+ context.menus = await webstoreApi.fetchMenus(opts);
2138
+ context.menus = Array.isArray(context.menus) ? context.menus : [];
2139
+ let mainMenu = null;
2140
+ if (context.menus.length > 0) {
2141
+ const main = context.menus.find((m) => (m.type || '').toLowerCase().trim() === 'main menu');
2142
+ const candidate = main || context.menus[0];
2143
+ if (candidate && candidate.id) {
2144
+ try {
2145
+ mainMenu = await webstoreApi.fetchMenuById(candidate.id, opts);
2146
+ } catch (e) {
2147
+ /* ignore */
2068
2148
  }
2069
- });
2149
+ }
2070
2150
  }
2071
- });
2072
-
2073
- // 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;
2151
+ context.mainMenu = mainMenu;
2152
+ } catch (e) {
2153
+ context.menus = [];
2154
+ context.mainMenu = null;
2155
+ }
2156
+
2157
+ try {
2158
+ const cartRes = await webstoreApi.fetchCart(opts);
2159
+ const data = cartRes?.data || cartRes;
2160
+ if (data) {
2161
+ context.cart = {
2162
+ ...context.cart,
2163
+ ...data,
2164
+ itemCount: data.items?.length ?? data.itemCount ?? 0,
2165
+ total: data.total ?? data.subTotal ?? 0
2166
+ };
2085
2167
  }
2086
- } else if (pageType === 'product' && extra.productHandle) {
2087
- // Load product data
2088
- // 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;
2089
2178
  } else if (pageType === 'collection') {
2090
- // Load products for collection
2091
- const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
2092
- context.products = productsResponse.products || [];
2093
- // Set collection.products for collection pages
2094
2179
  context.collection = context.collection || {};
2095
2180
  context.collection.products = context.products;
2181
+ } else if (pageType === 'product' && extra.productHandle) {
2182
+ const handle = String(extra.productHandle).toLowerCase().trim();
2183
+ let product = context.products.find((p) => {
2184
+ const h = (p.handle || '').toLowerCase().trim();
2185
+ const s = (p.slug || '').toLowerCase().trim();
2186
+ const id = String(p.id || '').toLowerCase().trim();
2187
+ const pid = String(p.productId || '').toLowerCase().trim();
2188
+ if (h === handle || s === handle || id === handle) return true;
2189
+ if (handle.startsWith('product-') && (pid === handle.replace(/^product-/, '') || id === handle.replace(/^product-/, ''))) return true;
2190
+ return false;
2191
+ });
2192
+ if (!product) {
2193
+ try {
2194
+ const idToFetch = handle.startsWith('product-') ? handle.replace(/^product-/, '') : handle;
2195
+ product = await webstoreApi.fetchProductById(idToFetch, opts);
2196
+ if (product) product = this.normalizeProductForRealMode(product);
2197
+ } catch (e) {
2198
+ /* ignore */
2199
+ }
2200
+ }
2201
+ if (product) context.product = product;
2096
2202
  }
2097
2203
  } catch (error) {
2098
- console.warn('[CONTEXT] Failed to load from API:', error.message);
2204
+ console.warn('[CONTEXT] Real mode fetch failed:', error.message);
2099
2205
  }
2100
2206
  }
2101
2207
 
@@ -2133,36 +2239,18 @@ class DevServer {
2133
2239
  // Widget templates use: widget.data.products, widget_data.products
2134
2240
  if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
2135
2241
  const widgetProducts = this.getProductsForWidget(widget, products);
2136
-
2137
- // 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
2242
+ const formatted = this.mode === 'real'
2243
+ ? this.normalizeProductsForRealMode(widgetProducts)
2244
+ : this.enrichProductsData(widgetProducts);
2245
+
2246
+ widget.data.products = formatted;
2247
+ widget.data.Products = formatted;
2248
+ widget.products = formatted;
2146
2249
  widget.data.content = widget.data.content || {};
2147
- widget.data.content.products = enrichedProducts;
2148
- widget.data.content.Products = enrichedProducts;
2149
-
2250
+ widget.data.content.products = formatted;
2251
+ widget.data.content.Products = formatted;
2150
2252
  enrichedCount++;
2151
- console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${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
- }
2253
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${formatted.length} products`);
2166
2254
  }
2167
2255
 
2168
2256
  // Enrich CategoryList and CategoryListCarousel widgets with categories
@@ -2199,18 +2287,18 @@ class DevServer {
2199
2287
 
2200
2288
  // Enrich RecentlyViewed widgets with products (use first 6 products as mock recent views)
2201
2289
  if (type === 'recentlyviewed' || type === 'recently-viewed') {
2202
- const recentProducts = this.enrichProductsData(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);
2203
2294
  widget.data.products = recentProducts;
2204
2295
  widget.data.Products = recentProducts;
2205
2296
  widget.products = recentProducts;
2206
-
2207
- // Also set in content
2208
2297
  widget.data.content = widget.data.content || {};
2209
2298
  widget.data.content.products = recentProducts;
2210
2299
  widget.data.content.Products = recentProducts;
2211
-
2212
2300
  enrichedCount++;
2213
- console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} enriched recently viewed products`);
2301
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
2214
2302
  }
2215
2303
  });
2216
2304
  });
@@ -2218,6 +2306,28 @@ class DevServer {
2218
2306
  console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
2219
2307
  }
2220
2308
 
2309
+ /**
2310
+ * Check if a product belongs to a category (for collection/category filtering).
2311
+ * Handles categoryId, primaryCategoryId, categoryHandle, and categories[].
2312
+ * @param {Object} p - Product
2313
+ * @param {Object} category - Category { id, handle }
2314
+ * @returns {boolean}
2315
+ */
2316
+ productMatchesCategory(p, category) {
2317
+ const cid = category.id;
2318
+ const ch = (category.handle || category.slug || '').toLowerCase();
2319
+ if (p.categoryId != null && (p.categoryId === cid || String(p.categoryId).toLowerCase() === String(cid).toLowerCase())) return true;
2320
+ if (p.primaryCategoryId != null && (p.primaryCategoryId === cid || String(p.primaryCategoryId).toLowerCase() === String(cid).toLowerCase())) return true;
2321
+ const ph = (p.categoryHandle || p.category?.handle || '').toLowerCase();
2322
+ if (ch && ph && ph === ch) return true;
2323
+ const arr = p.categories || p.categoryIds || [];
2324
+ if (Array.isArray(arr) && arr.some(cat => {
2325
+ const o = typeof cat === 'object' ? cat : { id: cat };
2326
+ return o.id === cid || (o.handle || o.slug || '').toLowerCase() === ch;
2327
+ })) return true;
2328
+ return false;
2329
+ }
2330
+
2221
2331
  /**
2222
2332
  * Enrich a single product with all required data (URL, name/title, price, images, stock)
2223
2333
  * This ensures consistency between widgets and pages
@@ -2254,42 +2364,24 @@ class DevServer {
2254
2364
  }
2255
2365
 
2256
2366
  // Ensure URL field exists - CRITICAL for product navigation
2257
- // 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;
2367
+ // Always use /products/{handle} so links target dev server (never external URLs)
2368
+ const handle = enriched.handle || enriched.slug || enriched.id;
2369
+ const effectiveHandle = (handle && String(handle).trim() && String(handle) !== '#')
2370
+ ? String(handle).trim().toLowerCase()
2371
+ : (enriched.productId ? `product-${enriched.productId}`.toLowerCase() : null);
2372
+ if (effectiveHandle) {
2373
+ enriched.url = `/products/${effectiveHandle}`;
2374
+ enriched.link = `/products/${effectiveHandle}`;
2375
+ if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') enriched.handle = effectiveHandle;
2376
+ if (!enriched.slug || enriched.slug === '#' || enriched.slug === '') enriched.slug = effectiveHandle;
2377
+ } else {
2378
+ enriched.url = '#';
2379
+ enriched.link = '#';
2380
+ console.warn(`[ENRICH] ⚠️ Product has no id/handle/slug/productId, URL set to '#'`, {
2381
+ productKeys: Object.keys(enriched).slice(0, 10)
2382
+ });
2291
2383
  }
2292
-
2384
+
2293
2385
  // Ensure handle/slug exist if missing (for template fallback logic)
2294
2386
  // Also normalize them (remove '#' or empty strings)
2295
2387
  if (!enriched.handle || enriched.handle === '#' || enriched.handle === '') {
@@ -2437,6 +2529,59 @@ class DevServer {
2437
2529
 
2438
2530
  return enriched;
2439
2531
  }
2532
+
2533
+ /**
2534
+ * Normalize real-API product for template use (no enrichment).
2535
+ * Aliases API keys (Description, Images, etc.) to template-expected keys,
2536
+ * sets url/link for dev navigation, and normalizes image items to { url, altText }.
2537
+ * @param {Object} p - Product from real API
2538
+ * @returns {Object} Normalized product (shallow copy + aliases)
2539
+ */
2540
+ normalizeProductForRealMode(p) {
2541
+ if (!p || typeof p !== 'object') return p;
2542
+ const n = { ...p };
2543
+ if (n.Description != null && n.description == null) n.description = n.Description;
2544
+ if (n.HtmlContent != null && n.htmlContent == null) n.htmlContent = n.HtmlContent;
2545
+ if (n.ShortDescription != null && n.shortDescription == null) n.shortDescription = n.ShortDescription;
2546
+ if (n.Name != null && n.name == null) n.name = n.Name;
2547
+ if (n.Title != null && n.title == null) n.title = n.Title;
2548
+ if (n.Attributes != null && n.attributes == null) n.attributes = n.Attributes;
2549
+ const t1 = n.ThumbnailImage1 || n.thumbnailImage1;
2550
+ if (t1 != null) {
2551
+ const u = typeof t1 === 'string' ? t1 : (t1.url || t1.Url || t1.imageUrl || t1.ImageUrl);
2552
+ if (u) {
2553
+ const a = typeof t1 === 'string' ? (n.name || n.title) : (t1.altText || t1.AltText || n.name || n.title);
2554
+ n.thumbnailImage1 = { url: u, altText: a };
2555
+ if (!n.thumbnailImage) n.thumbnailImage = n.thumbnailImage1;
2556
+ } else if (!n.thumbnailImage1) n.thumbnailImage1 = t1;
2557
+ }
2558
+ let imgs = n.images || n.Images;
2559
+ if (Array.isArray(imgs) && imgs.length > 0) {
2560
+ n.images = imgs.map((img) => {
2561
+ if (typeof img === 'string') return { url: img, altText: n.name || n.title };
2562
+ const u = img.url || img.Url || img.imageUrl || img.ImageUrl;
2563
+ const a = img.altText || img.AltText || n.name || n.title;
2564
+ return u ? { url: u, altText: a } : (typeof img === 'object' ? { url: img, altText: a } : { url: String(img), altText: a });
2565
+ });
2566
+ } else if (imgs) {
2567
+ n.images = Array.isArray(imgs) ? imgs : [imgs];
2568
+ } else {
2569
+ const single = n.imageUrl || n.ImageUrl;
2570
+ if (single) n.images = [{ url: single, altText: n.name || n.title }];
2571
+ }
2572
+ const handle = (n.handle || n.slug || n.id || '').toString().trim();
2573
+ const h = handle && handle !== '#' ? handle.toLowerCase() : (n.productId ? `product-${n.productId}` : null);
2574
+ if (h) {
2575
+ n.url = `/products/${h}`;
2576
+ n.link = `/products/${h}`;
2577
+ }
2578
+ return n;
2579
+ }
2580
+
2581
+ normalizeProductsForRealMode(products) {
2582
+ if (!Array.isArray(products)) return [];
2583
+ return products.map((p) => this.normalizeProductForRealMode(p));
2584
+ }
2440
2585
 
2441
2586
  /**
2442
2587
  * Enrich multiple products with all required data
@@ -2474,12 +2619,9 @@ class DevServer {
2474
2619
  );
2475
2620
  widgetProducts = sorted.slice(0, limit);
2476
2621
  } else {
2477
- // Default: return first N products
2478
2622
  widgetProducts = products.slice(0, limit);
2479
2623
  }
2480
-
2481
- // Enrich all products with required data before returning
2482
- return this.enrichProductsData(widgetProducts);
2624
+ return widgetProducts;
2483
2625
  }
2484
2626
 
2485
2627
  /**
@@ -3284,6 +3426,22 @@ class DevServer {
3284
3426
  logLevel: 'silent',
3285
3427
  timeout: 2000
3286
3428
  }));
3429
+ } else if (this.mode === 'real') {
3430
+ const base = (process.env.O2VEND_API_BASE_URL || '').replace(/\/$/, '').replace(/\/shopfront\/api\/v2\/?$/i, '').replace(/\/webstoreapi\/?$/i, '');
3431
+ if (base) {
3432
+ const { createProxyMiddleware } = require('http-proxy-middleware');
3433
+ const proxyOpts = {
3434
+ target: base,
3435
+ changeOrigin: true,
3436
+ logLevel: 'silent',
3437
+ timeout: parseInt(process.env.O2VEND_API_TIMEOUT, 10) || 10000,
3438
+ onProxyReq: (proxyReq, req) => {
3439
+ if (req.headers.cookie) proxyReq.setHeader('Cookie', req.headers.cookie);
3440
+ }
3441
+ };
3442
+ this.app.use('/webstoreapi', createProxyMiddleware(proxyOpts));
3443
+ this.app.use('/shopfront/api', createProxyMiddleware(proxyOpts));
3444
+ }
3287
3445
  }
3288
3446
 
3289
3447
  // Inject hot reload script into HTML responses