@o2vend/theme-cli 1.0.32

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.
Files changed (116) hide show
  1. package/README.md +425 -0
  2. package/assets/Logo_o2vend.png +0 -0
  3. package/assets/favicon.png +0 -0
  4. package/assets/logo-white.png +0 -0
  5. package/bin/o2vend +42 -0
  6. package/config/widget-map.json +50 -0
  7. package/lib/commands/check.js +201 -0
  8. package/lib/commands/generate.js +33 -0
  9. package/lib/commands/init.js +214 -0
  10. package/lib/commands/optimize.js +216 -0
  11. package/lib/commands/package.js +208 -0
  12. package/lib/commands/serve.js +105 -0
  13. package/lib/commands/validate.js +191 -0
  14. package/lib/lib/api-client.js +357 -0
  15. package/lib/lib/dev-server.js +2618 -0
  16. package/lib/lib/file-watcher.js +80 -0
  17. package/lib/lib/hot-reload.js +106 -0
  18. package/lib/lib/liquid-engine.js +822 -0
  19. package/lib/lib/liquid-filters.js +671 -0
  20. package/lib/lib/mock-api-server.js +989 -0
  21. package/lib/lib/mock-data.js +1468 -0
  22. package/lib/lib/widget-service.js +321 -0
  23. package/package.json +70 -0
  24. package/test-theme/README.md +27 -0
  25. package/test-theme/assets/async-sections.js +446 -0
  26. package/test-theme/assets/cart-drawer.js +463 -0
  27. package/test-theme/assets/cart-manager.js +223 -0
  28. package/test-theme/assets/checkout-price-handler.js +368 -0
  29. package/test-theme/assets/components.css +4629 -0
  30. package/test-theme/assets/delivery-zone.css +299 -0
  31. package/test-theme/assets/delivery-zone.js +396 -0
  32. package/test-theme/assets/logo.png +0 -0
  33. package/test-theme/assets/sections.css +48 -0
  34. package/test-theme/assets/theme.css +3500 -0
  35. package/test-theme/assets/theme.js +3745 -0
  36. package/test-theme/config/settings_data.json +292 -0
  37. package/test-theme/config/settings_schema.json +1050 -0
  38. package/test-theme/layout/theme.liquid +195 -0
  39. package/test-theme/locales/en.default.json +260 -0
  40. package/test-theme/sections/content-fallback.liquid +53 -0
  41. package/test-theme/sections/content.liquid +57 -0
  42. package/test-theme/sections/footer-fallback.liquid +328 -0
  43. package/test-theme/sections/footer.liquid +278 -0
  44. package/test-theme/sections/header-fallback.liquid +1805 -0
  45. package/test-theme/sections/header.liquid +1145 -0
  46. package/test-theme/sections/hero-fallback.liquid +212 -0
  47. package/test-theme/sections/hero.liquid +136 -0
  48. package/test-theme/snippets/account-sidebar.liquid +200 -0
  49. package/test-theme/snippets/add-to-cart-modal.liquid +484 -0
  50. package/test-theme/snippets/breadcrumbs.liquid +134 -0
  51. package/test-theme/snippets/cart-drawer.liquid +467 -0
  52. package/test-theme/snippets/delivery-zone-city-selector.liquid +79 -0
  53. package/test-theme/snippets/delivery-zone-modal.liquid +337 -0
  54. package/test-theme/snippets/delivery-zone-search.liquid +78 -0
  55. package/test-theme/snippets/icon.liquid +105 -0
  56. package/test-theme/snippets/login-modal.liquid +346 -0
  57. package/test-theme/snippets/mega-menu.liquid +812 -0
  58. package/test-theme/snippets/news-thumbnail.liquid +187 -0
  59. package/test-theme/snippets/pagination.liquid +120 -0
  60. package/test-theme/snippets/price.liquid +92 -0
  61. package/test-theme/snippets/product-card-related.liquid +78 -0
  62. package/test-theme/snippets/product-card-simple.liquid +41 -0
  63. package/test-theme/snippets/product-card.liquid +697 -0
  64. package/test-theme/snippets/rating.liquid +85 -0
  65. package/test-theme/snippets/skeleton-collection-grid.liquid +114 -0
  66. package/test-theme/snippets/skeleton-product-card.liquid +124 -0
  67. package/test-theme/snippets/skeleton-product-grid.liquid +34 -0
  68. package/test-theme/snippets/social-sharing.liquid +185 -0
  69. package/test-theme/templates/account/dashboard.liquid +401 -0
  70. package/test-theme/templates/account/loyalty-redemption.liquid +405 -0
  71. package/test-theme/templates/account/loyalty.liquid +588 -0
  72. package/test-theme/templates/account/order-detail.liquid +230 -0
  73. package/test-theme/templates/account/orders.liquid +349 -0
  74. package/test-theme/templates/account/profile.liquid +758 -0
  75. package/test-theme/templates/account/register.liquid +232 -0
  76. package/test-theme/templates/account/return-orders.liquid +348 -0
  77. package/test-theme/templates/account/store-credit.liquid +464 -0
  78. package/test-theme/templates/account/subscriptions.liquid +601 -0
  79. package/test-theme/templates/account/wishlist.liquid +419 -0
  80. package/test-theme/templates/address-book.liquid +1092 -0
  81. package/test-theme/templates/categories.liquid +452 -0
  82. package/test-theme/templates/checkout.liquid +4511 -0
  83. package/test-theme/templates/error.liquid +384 -0
  84. package/test-theme/templates/index.liquid +11 -0
  85. package/test-theme/templates/login.liquid +185 -0
  86. package/test-theme/templates/order-confirmation.liquid +720 -0
  87. package/test-theme/templates/page.liquid +297 -0
  88. package/test-theme/templates/product-detail.liquid +4363 -0
  89. package/test-theme/templates/products.liquid +518 -0
  90. package/test-theme/templates/search.liquid +922 -0
  91. package/test-theme/theme.json.example +19 -0
  92. package/test-theme/widgets/brand-carousel.liquid +676 -0
  93. package/test-theme/widgets/brand.liquid +245 -0
  94. package/test-theme/widgets/carousel.liquid +843 -0
  95. package/test-theme/widgets/category-list-carousel.liquid +656 -0
  96. package/test-theme/widgets/category-list.liquid +340 -0
  97. package/test-theme/widgets/category.liquid +475 -0
  98. package/test-theme/widgets/discount-time.liquid +176 -0
  99. package/test-theme/widgets/footer-menu.liquid +695 -0
  100. package/test-theme/widgets/footer.liquid +179 -0
  101. package/test-theme/widgets/gallery.liquid +271 -0
  102. package/test-theme/widgets/header-menu.liquid +932 -0
  103. package/test-theme/widgets/header.liquid +159 -0
  104. package/test-theme/widgets/html.liquid +214 -0
  105. package/test-theme/widgets/news.liquid +217 -0
  106. package/test-theme/widgets/product-canvas.liquid +235 -0
  107. package/test-theme/widgets/product-carousel.liquid +502 -0
  108. package/test-theme/widgets/product.liquid +45 -0
  109. package/test-theme/widgets/recently-viewed.liquid +26 -0
  110. package/test-theme/widgets/shared/product-grid.liquid +339 -0
  111. package/test-theme/widgets/simple-product.liquid +42 -0
  112. package/test-theme/widgets/single-product.liquid +610 -0
  113. package/test-theme/widgets/spacebar-carousel.liquid +663 -0
  114. package/test-theme/widgets/spacebar.liquid +279 -0
  115. package/test-theme/widgets/splash.liquid +378 -0
  116. package/test-theme/widgets/testimonial-carousel.liquid +709 -0
@@ -0,0 +1,2618 @@
1
+ /**
2
+ * Development Server
3
+ * Express server for local theme development with hot reload
4
+ */
5
+
6
+ const express = require('express');
7
+ const path = require('path');
8
+ const fs = require('fs-extra');
9
+ const { createLiquidEngine, renderWithLayout } = require('./liquid-engine');
10
+ const WidgetService = require('./widget-service');
11
+ const O2VendApiClient = require('./api-client');
12
+ const { setupHotReload } = require('./hot-reload');
13
+ const chalk = require('chalk');
14
+
15
+ class DevServer {
16
+ constructor(options = {}) {
17
+ this.themePath = options.themePath || process.cwd();
18
+ this.port = options.port || 3000;
19
+ this.host = options.host || 'localhost';
20
+ this.mode = options.mode || 'mock'; // 'mock' or 'real'
21
+ this.mockApiPort = options.mockApiPort || 3001;
22
+ this.openBrowser = options.open !== false;
23
+
24
+ this.app = express();
25
+ this.server = null;
26
+ this.io = null;
27
+ this.liquid = null;
28
+ this.widgetService = null;
29
+ this.apiClient = null;
30
+ this.mockApi = null;
31
+
32
+ // Setup server
33
+ this.setupMiddleware();
34
+ this.setupRoutes();
35
+ }
36
+
37
+ /**
38
+ * Setup Express middleware
39
+ */
40
+ setupMiddleware() {
41
+ // Body parser with error handling for aborted requests
42
+ this.app.use((req, res, next) => {
43
+ // Handle request aborted errors gracefully
44
+ req.on('aborted', () => {
45
+ // Request was aborted - this is normal (e.g., navigation, refresh)
46
+ // Don't log as error
47
+ });
48
+ next();
49
+ });
50
+
51
+ // Body parser
52
+ this.app.use(express.json());
53
+ this.app.use(express.urlencoded({ extended: true }));
54
+
55
+ // Error handler for body parser (catches aborted requests)
56
+ this.app.use((error, req, res, next) => {
57
+ // Ignore request aborted errors - they're harmless
58
+ if (error && (error.code === 'ECONNRESET' || error.message === 'request aborted' || error.type === 'entity.parse.failed')) {
59
+ return res.status(400).end();
60
+ }
61
+ next(error);
62
+ });
63
+
64
+ // Middleware to rewrite .min.css to .css and .min.js to .js
65
+ // This allows templates to reference minified files but serve non-minified in dev
66
+ this.app.use((req, res, next) => {
67
+ if (req.path && req.path.match(/\.min\.(css|js)$/)) {
68
+ // Rewrite the request URL to remove .min (req.path is read-only, so we modify req.url)
69
+ req.url = req.url.replace(/\.min\.(css|js)$/, '.$1');
70
+ }
71
+ next();
72
+ });
73
+
74
+ // Middleware to rewrite /themes/default/assets/ to /assets/
75
+ // This handles cases where templates output full theme paths
76
+ this.app.use((req, res, next) => {
77
+ if (req.path && req.path.match(/^\/themes\/[^\/]+\/assets\//)) {
78
+ // Rewrite /themes/default/assets/logo.png to /assets/logo.png
79
+ req.url = req.url.replace(/^\/themes\/[^\/]+\/assets\//, '/assets/');
80
+ }
81
+ next();
82
+ });
83
+
84
+ // Middleware to fallback .min.css to .css and .min.js to .js if minified version doesn't exist
85
+ // This is useful for development when minified assets haven't been generated
86
+ this.app.use('/assets', (req, res, next) => {
87
+ const fs = require('fs');
88
+ const assetPath = path.join(this.themePath, 'assets', req.path);
89
+
90
+ // Check if requesting a minified file that doesn't exist
91
+ if (req.path.includes('.min.')) {
92
+ if (!fs.existsSync(assetPath)) {
93
+ // Try the non-minified version
94
+ const nonMinPath = req.path.replace('.min.', '.');
95
+ const nonMinFullPath = path.join(this.themePath, 'assets', nonMinPath);
96
+
97
+ if (fs.existsSync(nonMinFullPath)) {
98
+ // Rewrite to non-minified version
99
+ req.url = nonMinPath;
100
+ console.log(`[DEV SERVER] Fallback: ${req.path} → ${nonMinPath}`);
101
+ }
102
+ }
103
+ }
104
+ next();
105
+ });
106
+
107
+ // Process CSS files through Liquid to support theme variables
108
+ // This handles CSS files that contain Liquid syntax like {{ settings.color_primary }}
109
+ this.app.get('/assets/:filename.css', async (req, res, next) => {
110
+ try {
111
+ // Get filename from params
112
+ const filename = req.params.filename + '.css';
113
+ const cssPath = path.join(this.themePath, 'assets', filename);
114
+
115
+ if (!fs.existsSync(cssPath)) {
116
+ return next();
117
+ }
118
+
119
+ const cssContent = fs.readFileSync(cssPath, 'utf8');
120
+
121
+ // Check if CSS contains Liquid variable syntax ({{ }})
122
+ // Only process if it contains actual Liquid variables, not just comments
123
+ if (cssContent.includes('{{') && cssContent.includes('settings.')) {
124
+ try {
125
+ // Load settings from theme config
126
+ const settings = this.loadThemeSettings();
127
+
128
+ // Process through Liquid
129
+ const processedCss = await this.liquid.parseAndRender(cssContent, { settings });
130
+
131
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
132
+ res.setHeader('Cache-Control', 'no-cache');
133
+ return res.send(processedCss);
134
+ } catch (liquidError) {
135
+ console.error(`[CSS] Liquid processing error for ${filename}:`, liquidError.message);
136
+ // Fall through to serve static file
137
+ }
138
+ }
139
+
140
+ // Serve static file if no Liquid processing needed or if processing failed
141
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
142
+ res.sendFile(cssPath);
143
+ } catch (error) {
144
+ console.error(`[CSS] Error processing ${req.path}:`, error.message);
145
+ next();
146
+ }
147
+ });
148
+
149
+ // Static files (theme assets) with proper MIME types
150
+ this.app.use('/assets', express.static(path.join(this.themePath, 'assets'), {
151
+ setHeaders: (res, filePath) => {
152
+ // Set correct MIME types for all asset types
153
+ const ext = path.extname(filePath).toLowerCase();
154
+ const mimeTypes = {
155
+ '.css': 'text/css; charset=utf-8',
156
+ '.js': 'application/javascript; charset=utf-8',
157
+ '.jpg': 'image/jpeg',
158
+ '.jpeg': 'image/jpeg',
159
+ '.png': 'image/png',
160
+ '.gif': 'image/gif',
161
+ '.svg': 'image/svg+xml',
162
+ '.webp': 'image/webp',
163
+ '.woff': 'font/woff',
164
+ '.woff2': 'font/woff2',
165
+ '.ttf': 'font/ttf',
166
+ '.eot': 'application/vnd.ms-fontobject'
167
+ };
168
+
169
+ if (mimeTypes[ext]) {
170
+ res.setHeader('Content-Type', mimeTypes[ext]);
171
+ }
172
+ }
173
+ }));
174
+
175
+ // Handle 404 for missing assets - ensure correct MIME type
176
+ this.app.use('/assets', (req, res, next) => {
177
+ // Only handle if response hasn't been sent (file not found)
178
+ if (!res.headersSent) {
179
+ const ext = path.extname(req.path).toLowerCase();
180
+ if (ext === '.css') {
181
+ res.setHeader('Content-Type', 'text/css; charset=utf-8');
182
+ } else if (ext === '.js') {
183
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
184
+ }
185
+ res.status(404).end();
186
+ } else {
187
+ next();
188
+ }
189
+ });
190
+
191
+ // Serve images from assets
192
+ this.app.use('/images', express.static(path.join(this.themePath, 'assets')));
193
+
194
+ // Serve O2VEND assets (logo, favicon) for dev dashboard
195
+ // Try CLI package assets first (for installed package), then shared folder (for development)
196
+ const cliAssetsPath = path.join(__dirname, '..', '..', 'assets');
197
+ const sharedAssetsPath = path.join(__dirname, '..', '..', '..', '..', 'shared', 'assets');
198
+ const o2vendAssetsPath = fs.existsSync(cliAssetsPath) ? cliAssetsPath : sharedAssetsPath;
199
+ this.app.use('/o2vend-assets', express.static(o2vendAssetsPath, {
200
+ setHeaders: (res, filePath) => {
201
+ const ext = path.extname(filePath).toLowerCase();
202
+ if (ext === '.png') res.setHeader('Content-Type', 'image/png');
203
+ if (ext === '.svg') res.setHeader('Content-Type', 'image/svg+xml');
204
+ if (ext === '.ico') res.setHeader('Content-Type', 'image/x-icon');
205
+ }
206
+ }));
207
+
208
+ // Request logging
209
+ this.app.use((req, res, next) => {
210
+ const method = req.method;
211
+ const url = req.url;
212
+ const status = res.statusCode;
213
+ console.log(chalk.gray(`${method} ${url} ${status}`));
214
+ next();
215
+ });
216
+ }
217
+
218
+ /**
219
+ * Setup API client and widget service
220
+ */
221
+ async setupServices() {
222
+ if (this.mode === 'real') {
223
+ // Real API mode - create API client from environment
224
+ const tenantId = process.env.O2VEND_TENANT_ID;
225
+ const apiKey = process.env.O2VEND_API_KEY;
226
+ const baseUrl = process.env.O2VEND_API_BASE_URL;
227
+
228
+ if (!tenantId || !apiKey || !baseUrl) {
229
+ console.warn(chalk.yellow('āš ļø Real API mode requires O2VEND_TENANT_ID, O2VEND_API_KEY, and O2VEND_API_BASE_URL'));
230
+ console.warn(chalk.yellow(' Falling back to mock mode'));
231
+ this.mode = 'mock';
232
+ } else {
233
+ this.apiClient = new O2VendApiClient(tenantId, apiKey, baseUrl);
234
+ }
235
+ }
236
+
237
+ // In mock mode, create API client - will be updated after mock API starts
238
+ // We create a placeholder here, actual client will be created in start() after mock API is running
239
+ if (this.mode === 'mock') {
240
+ // Create a temporary client - will be replaced in start() method
241
+ this.apiClient = null;
242
+ }
243
+
244
+ // Create widget service (works with mock or real API)
245
+ // Note: In mock mode, this will be recreated after mock API starts
246
+ if (this.apiClient) {
247
+ this.widgetService = new WidgetService(this.apiClient, {
248
+ theme: path.basename(this.themePath),
249
+ themePath: this.themePath
250
+ });
251
+ }
252
+
253
+ // Create Liquid engine
254
+ this.liquid = createLiquidEngine(this.themePath, {
255
+ cache: false // Disable cache in dev mode
256
+ });
257
+
258
+ // Register widget render filter
259
+ this.setupWidgetFilter();
260
+ }
261
+
262
+ /**
263
+ * Register widget render filter
264
+ */
265
+ setupWidgetFilter() {
266
+ // Get reference to currentRenderingContext from liquid-engine
267
+ const liquidEngine = require('./liquid-engine');
268
+
269
+ // Store reference to this for use in filter
270
+ const self = this;
271
+
272
+ // Register the filter - LiquidJS supports async filters
273
+ // IMPORTANT: The filter must be registered AFTER widgetService is available
274
+ console.log('[DEV SERVER] Registering render_widget filter...');
275
+ console.log(`[DEV SERVER] WidgetService available: ${!!this.widgetService}`);
276
+ console.log(`[DEV SERVER] Theme path: ${this.themePath}`);
277
+
278
+ // TEST: Register a simple test filter first to verify filter mechanism works
279
+ this.liquid.registerFilter('test_widget', function(widget) {
280
+ console.log(chalk.magenta(`[TEST FILTER] test_widget filter called with: ${typeof widget}`));
281
+ if (typeof widget === 'object' && widget !== null) {
282
+ console.log(chalk.magenta(`[TEST FILTER] Widget object keys: ${Object.keys(widget).join(', ')}`));
283
+ }
284
+ return `[TEST: ${typeof widget}]`;
285
+ });
286
+
287
+ // Register async filter - LiquidJS 10.7.0 supports async filters that return Promises
288
+ // CRITICAL: LiquidJS may not call async filters in output tags {{ }} properly
289
+ // If this doesn't work, templates should use {% render_widget widget %} tag instead
290
+ //
291
+ // IMPORTANT: We're using a synchronous wrapper that returns a Promise
292
+ // This ensures LiquidJS recognizes it as a filter, but we handle async internally
293
+ this.liquid.registerFilter('render_widget', function(widget, context) {
294
+ // Return a Promise - LiquidJS should await it
295
+ return Promise.resolve().then(async () => {
296
+ try {
297
+ console.log(chalk.cyan(`[WIDGET] ⚔ render_widget filter called`));
298
+ console.log(chalk.gray(`[WIDGET] Input type: ${typeof widget}, value: ${widget === null ? 'null' : widget === undefined ? 'undefined' : (typeof widget === 'object' ? `object with keys: ${Object.keys(widget || {}).join(', ')}` : String(widget).substring(0, 50))}`));
299
+
300
+ // Handle case where LiquidJS might pass a string representation
301
+ if (typeof widget === 'string' && widget === '[object Object]') {
302
+ console.warn(chalk.yellow(`[WIDGET] āš ļø Widget was converted to string '[object Object]' - LiquidJS may not support object filters`));
303
+ return '<div class="widget-error">Widget filter received string instead of object. Use {% render_widget widget %} tag instead.</div>';
304
+ }
305
+
306
+ // DEBUG: Log widget object structure
307
+ if (widget && typeof widget === 'object') {
308
+ console.log(chalk.gray(`[WIDGET] Widget type: ${widget?.type || 'undefined'}, ID: ${widget?.id || 'undefined'}`));
309
+ console.log(chalk.gray(`[WIDGET] Widget keys: ${Object.keys(widget).join(', ')}`));
310
+ }
311
+
312
+ if (!widget || !widget.type) {
313
+ console.warn(chalk.yellow(`[WIDGET] āš ļø No widget or widget.type provided`));
314
+ console.warn(chalk.gray(`[WIDGET] Widget object: ${widget ? JSON.stringify(Object.keys(widget)) : 'null'}`));
315
+ return '';
316
+ }
317
+
318
+ // Ensure widgetService is available - use self to access instance
319
+ if (!self.widgetService) {
320
+ console.error('[WIDGET] āŒ WidgetService not initialized - cannot render widget');
321
+ console.error('[WIDGET] This may happen if widgetService was not created properly');
322
+ return `<div class="widget-error" style="padding: 10px; background: #ffebee; border-left: 3px solid #f44336; margin: 10px 0;">
323
+ <strong>Widget Service Error:</strong> WidgetService not initialized<br>
324
+ <small>Widget type: ${widget.type}, ID: ${widget.id}</small>
325
+ </div>`;
326
+ }
327
+
328
+ try {
329
+ const templateSlug = self.widgetService.getTemplateSlug(widget.type);
330
+ const widgetPath = path.join(self.themePath, 'widgets', `${templateSlug}.liquid`);
331
+
332
+ console.log(`[WIDGET] Looking for widget template: ${widgetPath}`);
333
+
334
+ if (!fs.existsSync(widgetPath)) {
335
+ // Try alternative paths (e.g., shared/product-grid.liquid)
336
+ const altPath = path.join(self.themePath, 'widgets', 'shared', `${templateSlug}.liquid`);
337
+ console.log(`[WIDGET] Template not found at ${widgetPath}, trying: ${altPath}`);
338
+ if (fs.existsSync(altPath)) {
339
+ console.log(`[WIDGET] āœ… Using shared widget: ${templateSlug}.liquid`);
340
+ const widgetContent = fs.readFileSync(altPath, 'utf8');
341
+ // Get full context - use current rendering context if available, otherwise use passed context
342
+ const fullContext = liquidEngine.getCurrentContext ? liquidEngine.getCurrentContext() : null;
343
+ const widgetContext = fullContext
344
+ ? { ...fullContext, widget: widget }
345
+ : (typeof context === 'object' && context !== null
346
+ ? { ...context, widget: widget }
347
+ : { widget: widget });
348
+ const rendered = await self.liquid.parseAndRender(widgetContent, widgetContext);
349
+ console.log(`[WIDGET] āœ… Rendered widget ${widget.type} (${rendered.length} bytes)`);
350
+ return rendered;
351
+ }
352
+
353
+ console.error(`[WIDGET] āŒ Template not found: ${templateSlug}.liquid (tried: ${widgetPath}, ${altPath})`);
354
+ console.error(`[WIDGET] Theme path: ${self.themePath}`);
355
+ console.error(`[WIDGET] Widget type: ${widget.type}, Template slug: ${templateSlug}`);
356
+ return `<div class="widget-error" style="padding: 10px; background: #ffebee; border-left: 3px solid #f44336; margin: 10px 0;">
357
+ <strong>Widget template not found:</strong> ${templateSlug}.liquid<br>
358
+ <small>Widget type: ${widget.type}, ID: ${widget.id}</small><br>
359
+ <small>Tried paths: ${widgetPath}, ${altPath}</small>
360
+ </div>`;
361
+ }
362
+
363
+ console.log(`[WIDGET] āœ… Found widget template: ${widgetPath}`);
364
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
365
+ // Get full context - use current rendering context if available
366
+ const fullContext = liquidEngine.getCurrentContext ? liquidEngine.getCurrentContext() : null;
367
+ const widgetContext = fullContext
368
+ ? { ...fullContext, widget: widget }
369
+ : (typeof context === 'object' && context !== null
370
+ ? { ...context, widget: widget }
371
+ : { widget: widget });
372
+
373
+ console.log(`[WIDGET] Rendering ${widget.type} (${templateSlug}) with context keys: ${Object.keys(widgetContext).join(', ')}`);
374
+ const rendered = await self.liquid.parseAndRender(widgetContent, widgetContext);
375
+ console.log(`[WIDGET] āœ… Successfully rendered widget ${widget.type} (${rendered.length} bytes)`);
376
+ return rendered;
377
+ } catch (error) {
378
+ console.error(`[WIDGET] Error rendering widget ${widget.type}:`, error.message);
379
+ console.error(`[WIDGET] Stack:`, error.stack);
380
+ return `<div class="widget-error" style="padding: 10px; background: #ffebee; border-left: 3px solid #f44336; margin: 10px 0;">
381
+ <strong>Error rendering widget:</strong> ${error.message}<br>
382
+ <small>Widget type: ${widget.type}, ID: ${widget.id}</small>
383
+ </div>`;
384
+ }
385
+ } catch (outerError) {
386
+ // Catch any errors in filter registration or invocation
387
+ console.error(`[WIDGET] āŒ CRITICAL: Filter invocation error:`, outerError.message);
388
+ console.error(`[WIDGET] Stack:`, outerError.stack);
389
+ return `<div class="widget-error" style="padding: 10px; background: #ff0000; color: white; border-left: 3px solid #cc0000; margin: 10px 0;">
390
+ <strong>CRITICAL: Filter Error:</strong> ${outerError.message}<br>
391
+ <small>This indicates a problem with the render_widget filter itself</small>
392
+ </div>`;
393
+ }
394
+ });
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Setup Express routes
400
+ */
401
+ setupRoutes() {
402
+ // Homepage
403
+ this.app.get('/', async (req, res, next) => {
404
+ try {
405
+ const context = await this.buildContext(req, 'home');
406
+ // Log context data for debugging
407
+ const widgetSections = Object.keys(context.widgets || {});
408
+ const widgetCounts = widgetSections.map(s => `${s}:${context.widgets[s]?.length || 0}`).join(', ');
409
+ console.log(`[HOME] Products: ${context.products?.length || 0}, Widgets: [${widgetCounts}], Menus: ${context.menus?.length || 0}`);
410
+
411
+ // Ensure products are available
412
+ if (!context.products || context.products.length === 0) {
413
+ console.warn('[HOME] No products in context, attempting reload...');
414
+ try {
415
+ if (this.apiClient) {
416
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
417
+ context.products = productsResponse.products || productsResponse.data?.products || [];
418
+ console.log(`[HOME] Reloaded ${context.products.length} products`);
419
+ } else {
420
+ console.error('[HOME] API client not available');
421
+ }
422
+ } catch (error) {
423
+ console.error('[HOME] Failed to reload products:', error.message);
424
+ }
425
+ }
426
+
427
+ // Ensure widgets are available
428
+ if (!context.widgets || Object.keys(context.widgets).length === 0) {
429
+ console.warn('[HOME] No widgets in context, attempting reload...');
430
+ try {
431
+ if (this.widgetService) {
432
+ const widgets = await this.widgetService.getWidgetsBySections();
433
+ context.widgets = widgets || { hero: [], content: [], footer: [] };
434
+ console.log(`[HOME] Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
435
+ } else {
436
+ console.error('[HOME] Widget service not available');
437
+ context.widgets = { hero: [], content: [], footer: [] };
438
+ }
439
+ } catch (error) {
440
+ console.error('[HOME] Failed to reload widgets:', error.message);
441
+ context.widgets = { hero: [], content: [], footer: [] };
442
+ }
443
+ }
444
+
445
+ const html = await renderWithLayout(this.liquid, 'templates/index', context, this.themePath);
446
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
447
+ res.send(html);
448
+ } catch (error) {
449
+ next(error);
450
+ }
451
+ });
452
+
453
+ // Products listing page
454
+ this.app.get('/products', async (req, res, next) => {
455
+ try {
456
+ const context = await this.buildContext(req, 'products');
457
+
458
+ // DEBUG: Log context data for /products page
459
+ console.log(`[PRODUCTS PAGE] Context summary:`);
460
+ console.log(` - Products count: ${context.products?.length || 0}`);
461
+ console.log(` - Widgets sections: ${Object.keys(context.widgets || {}).join(', ') || 'none'}`);
462
+ console.log(` - Widgets counts: ${Object.keys(context.widgets || {}).map(s => `${s}:${context.widgets[s]?.length || 0}`).join(', ') || 'none'}`);
463
+ console.log(` - Menus count: ${context.menus?.length || 0}`);
464
+ console.log(` - Categories count: ${context.categories?.length || 0}`);
465
+ console.log(` - Brands count: ${context.brands?.length || 0}`);
466
+
467
+ // Ensure products are loaded for products page
468
+ if (!context.products || context.products.length === 0) {
469
+ console.warn('[PRODUCTS PAGE] āš ļø No products in context, attempting to reload...');
470
+ try {
471
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
472
+ context.products = productsResponse.products || productsResponse.data?.products || [];
473
+ console.log(`[PRODUCTS PAGE] āœ… Reloaded ${context.products.length} products`);
474
+ } catch (error) {
475
+ console.error('[PRODUCTS PAGE] āŒ Failed to load products:', error.message);
476
+ }
477
+ } else {
478
+ // DEBUG: Verify products have quantity/stock on products page
479
+ if (context.products.length > 0) {
480
+ const sampleProduct = context.products[0];
481
+ const hasStock = 'stock' in sampleProduct || 'quantity' in sampleProduct;
482
+ // Use nullish coalescing to show 0 values correctly
483
+ const stockValue = sampleProduct.stock ?? sampleProduct.quantity ?? 'N/A';
484
+ console.log(`[PRODUCTS PAGE DEBUG] Sample product - Title: ${sampleProduct.title || sampleProduct.name}, Has stock: ${hasStock}, Stock: ${stockValue}`);
485
+ console.log(`[PRODUCTS PAGE DEBUG] Product stock details: stock=${sampleProduct.stock}, quantity=${sampleProduct.quantity}, inStock=${sampleProduct.inStock}`);
486
+
487
+ // Check a few more products to see stock distribution
488
+ const stockCounts = context.products.slice(0, 5).map(p => p.stock ?? 0);
489
+ console.log(`[PRODUCTS PAGE DEBUG] Stock values for first 5 products: ${stockCounts.join(', ')}`);
490
+ }
491
+ }
492
+
493
+ // Ensure widgets are available (they should be from buildContext, but double-check)
494
+ if (!context.widgets || Object.keys(context.widgets).length === 0) {
495
+ console.warn('[PRODUCTS PAGE] āš ļø No widgets in context, attempting to reload...');
496
+ try {
497
+ if (this.widgetService) {
498
+ const widgets = await this.widgetService.getWidgetsBySections();
499
+ context.widgets = widgets || { hero: [], content: [], footer: [], header: [] };
500
+ console.log(`[PRODUCTS PAGE] āœ… Reloaded widgets: ${Object.keys(context.widgets).join(', ')}`);
501
+ } else {
502
+ console.error('[PRODUCTS PAGE] āŒ Widget service not available');
503
+ context.widgets = { hero: [], content: [], footer: [], header: [] };
504
+ }
505
+ } catch (error) {
506
+ console.error('[PRODUCTS PAGE] āŒ Failed to reload widgets:', error.message);
507
+ context.widgets = { hero: [], content: [], footer: [], header: [] };
508
+ }
509
+ }
510
+
511
+ // Ensure menus are available
512
+ if (!context.menus || context.menus.length === 0) {
513
+ console.warn('[PRODUCTS PAGE] āš ļø No menus in context, attempting to reload...');
514
+ try {
515
+ const axios = require('axios');
516
+ const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
517
+ const menusData = menusResponse.data;
518
+ context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
519
+ console.log(`[PRODUCTS PAGE] āœ… Reloaded ${context.menus.length} menus`);
520
+ } catch (error) {
521
+ console.error('[PRODUCTS PAGE] āŒ Failed to reload menus:', error.message);
522
+ context.menus = [];
523
+ }
524
+ }
525
+
526
+ // Ensure collection.products is set for products template
527
+ if (context.products && context.products.length > 0) {
528
+ context.collection = context.collection || {};
529
+ context.collection.products = context.products;
530
+ context.collection.title = 'All Products';
531
+ context.collection.handle = 'all';
532
+ context.collection.totalProducts = context.products.length;
533
+ }
534
+
535
+ const html = await renderWithLayout(this.liquid, 'templates/products', context, this.themePath);
536
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
537
+ res.send(html);
538
+ } catch (error) {
539
+ // If products template doesn't exist, try collections template as fallback
540
+ if (error.message && error.message.includes('Template not found')) {
541
+ try {
542
+ const context = await this.buildContext(req, 'collection');
543
+ const html = await renderWithLayout(this.liquid, 'templates/collection', context, this.themePath);
544
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
545
+ res.send(html);
546
+ } catch (fallbackError) {
547
+ next(error);
548
+ }
549
+ } else {
550
+ next(error);
551
+ }
552
+ }
553
+ });
554
+
555
+ // Product detail page
556
+ this.app.get('/products/:handle', async (req, res, next) => {
557
+ try {
558
+ const context = await this.buildContext(req, 'product', { productHandle: req.params.handle });
559
+ // Try multiple template names: product-detail, product, product-page
560
+ let html;
561
+ const templateOptions = ['templates/product-detail', 'templates/product', 'templates/product-page'];
562
+ for (const template of templateOptions) {
563
+ try {
564
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
565
+ break;
566
+ } catch (error) {
567
+ if (!error.message?.includes('Template not found')) {
568
+ throw error;
569
+ }
570
+ }
571
+ }
572
+ if (!html) {
573
+ throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
574
+ }
575
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
576
+ res.send(html);
577
+ } catch (error) {
578
+ next(error);
579
+ }
580
+ });
581
+
582
+ // Alternative product URL formats (some themes use these)
583
+ // Format: /product-{id}
584
+ this.app.get('/product-:id', async (req, res, next) => {
585
+ try {
586
+ const context = await this.buildContext(req, 'product', { productHandle: `product-${req.params.id}` });
587
+ let html;
588
+ const templateOptions = ['templates/product-detail', 'templates/product', 'templates/product-page'];
589
+ for (const template of templateOptions) {
590
+ try {
591
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
592
+ break;
593
+ } catch (error) {
594
+ if (!error.message?.includes('Template not found')) {
595
+ throw error;
596
+ }
597
+ }
598
+ }
599
+ if (!html) {
600
+ throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
601
+ }
602
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
603
+ res.send(html);
604
+ } catch (error) {
605
+ next(error);
606
+ }
607
+ });
608
+
609
+ // Format: /product/{id} (alternative without hyphen)
610
+ this.app.get('/product/:id', async (req, res, next) => {
611
+ try {
612
+ const context = await this.buildContext(req, 'product', { productHandle: `product-${req.params.id}` });
613
+ let html;
614
+ const templateOptions = ['templates/product-detail', 'templates/product', 'templates/product-page'];
615
+ for (const template of templateOptions) {
616
+ try {
617
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
618
+ break;
619
+ } catch (error) {
620
+ if (!error.message?.includes('Template not found')) {
621
+ throw error;
622
+ }
623
+ }
624
+ }
625
+ if (!html) {
626
+ throw new Error(`Product template not found. Tried: ${templateOptions.join(', ')}`);
627
+ }
628
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
629
+ res.send(html);
630
+ } catch (error) {
631
+ next(error);
632
+ }
633
+ });
634
+
635
+ // Collections listing page (uses categories.liquid template per webstore)
636
+ this.app.get('/collections', async (req, res, next) => {
637
+ try {
638
+ const context = await this.buildContext(req, 'collections');
639
+ // Try categories.liquid first (as per webstore), then collections.liquid, then collection.liquid
640
+ let html;
641
+ try {
642
+ html = await renderWithLayout(this.liquid, 'templates/categories', context, this.themePath);
643
+ } catch (error) {
644
+ if (error.message && error.message.includes('Template not found')) {
645
+ try {
646
+ html = await renderWithLayout(this.liquid, 'templates/collections', context, this.themePath);
647
+ } catch (error2) {
648
+ html = await renderWithLayout(this.liquid, 'templates/collection', context, this.themePath);
649
+ }
650
+ } else {
651
+ throw error;
652
+ }
653
+ }
654
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
655
+ res.send(html);
656
+ } catch (error) {
657
+ next(error);
658
+ }
659
+ });
660
+
661
+ // Collection detail page - Try categories first, then collection
662
+ this.app.get('/collections/:handle', async (req, res, next) => {
663
+ try {
664
+ const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
665
+ // Set category in context based on handle
666
+ const category = context.categories?.find(c =>
667
+ c.handle === req.params.handle ||
668
+ c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
669
+ );
670
+ if (category) {
671
+ context.category = category;
672
+ context.collection = category;
673
+ }
674
+
675
+ let html;
676
+ const templateOptions = ['templates/categories', 'templates/category', 'templates/collection'];
677
+ for (const template of templateOptions) {
678
+ try {
679
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
680
+ break;
681
+ } catch (error) {
682
+ if (!error.message?.includes('Template not found')) {
683
+ throw error;
684
+ }
685
+ }
686
+ }
687
+ if (!html) {
688
+ throw new Error(`Category/Collection template not found. Tried: ${templateOptions.join(', ')}`);
689
+ }
690
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
691
+ res.send(html);
692
+ } catch (error) {
693
+ next(error);
694
+ }
695
+ });
696
+
697
+ // Category routes (alias for collections - some themes use /categories/)
698
+ this.app.get('/categories', async (req, res, next) => {
699
+ try {
700
+ const context = await this.buildContext(req, 'collections');
701
+ let html;
702
+ const templateOptions = ['templates/categories', 'templates/collections', 'templates/collection'];
703
+ for (const template of templateOptions) {
704
+ try {
705
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
706
+ break;
707
+ } catch (error) {
708
+ if (!error.message?.includes('Template not found')) {
709
+ throw error;
710
+ }
711
+ }
712
+ }
713
+ if (!html) {
714
+ throw new Error(`Categories template not found. Tried: ${templateOptions.join(', ')}`);
715
+ }
716
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
717
+ res.send(html);
718
+ } catch (error) {
719
+ next(error);
720
+ }
721
+ });
722
+
723
+ this.app.get('/categories/:handle', async (req, res, next) => {
724
+ try {
725
+ const context = await this.buildContext(req, 'collection', { collectionHandle: req.params.handle });
726
+ const category = context.categories?.find(c =>
727
+ c.handle === req.params.handle ||
728
+ c.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
729
+ );
730
+ if (category) {
731
+ context.category = category;
732
+ context.collection = category;
733
+ }
734
+
735
+ let html;
736
+ const templateOptions = ['templates/categories', 'templates/category', 'templates/collection'];
737
+ for (const template of templateOptions) {
738
+ try {
739
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
740
+ break;
741
+ } catch (error) {
742
+ if (!error.message?.includes('Template not found')) {
743
+ throw error;
744
+ }
745
+ }
746
+ }
747
+ if (!html) {
748
+ throw new Error(`Category template not found. Tried: ${templateOptions.join(', ')}`);
749
+ }
750
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
751
+ res.send(html);
752
+ } catch (error) {
753
+ next(error);
754
+ }
755
+ });
756
+
757
+ // Brand routes
758
+ this.app.get('/brands', async (req, res, next) => {
759
+ try {
760
+ const context = await this.buildContext(req, 'brands');
761
+ let html;
762
+ const templateOptions = ['templates/brands', 'templates/brand-list', 'templates/collections'];
763
+ for (const template of templateOptions) {
764
+ try {
765
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
766
+ break;
767
+ } catch (error) {
768
+ if (!error.message?.includes('Template not found')) {
769
+ throw error;
770
+ }
771
+ }
772
+ }
773
+ if (!html) {
774
+ // Create a simple brands listing page
775
+ html = this.createSimpleBrandsPage(context);
776
+ }
777
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
778
+ res.send(html);
779
+ } catch (error) {
780
+ next(error);
781
+ }
782
+ });
783
+
784
+ this.app.get('/brands/:handle', async (req, res, next) => {
785
+ try {
786
+ const context = await this.buildContext(req, 'brand', { brandHandle: req.params.handle });
787
+ const brand = context.brands?.find(b =>
788
+ b.handle === req.params.handle ||
789
+ b.name?.toLowerCase().replace(/\s+/g, '-') === req.params.handle
790
+ );
791
+ if (brand) {
792
+ context.brand = brand;
793
+ // Filter products by this brand
794
+ context.collection = {
795
+ ...brand,
796
+ products: context.products?.filter(p => p.brandId === brand.id) || []
797
+ };
798
+ }
799
+
800
+ let html;
801
+ const templateOptions = ['templates/brand', 'templates/brands', 'templates/collection'];
802
+ for (const template of templateOptions) {
803
+ try {
804
+ html = await renderWithLayout(this.liquid, template, context, this.themePath);
805
+ break;
806
+ } catch (error) {
807
+ if (!error.message?.includes('Template not found')) {
808
+ throw error;
809
+ }
810
+ }
811
+ }
812
+ if (!html) {
813
+ html = this.createSimpleBrandPage(context, brand);
814
+ }
815
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
816
+ res.send(html);
817
+ } catch (error) {
818
+ next(error);
819
+ }
820
+ });
821
+
822
+ // Cart page
823
+ this.app.get('/cart', async (req, res, next) => {
824
+ try {
825
+ const context = await this.buildContext(req, 'cart');
826
+ const html = await renderWithLayout(this.liquid, 'templates/cart', context, this.themePath);
827
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
828
+ res.send(html);
829
+ } catch (error) {
830
+ next(error);
831
+ }
832
+ });
833
+
834
+ // Search page
835
+ this.app.get('/search', async (req, res, next) => {
836
+ try {
837
+ const context = await this.buildContext(req, 'search', { query: req.query.q });
838
+ const html = await renderWithLayout(this.liquid, 'templates/search', context, this.themePath);
839
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
840
+ res.send(html);
841
+ } catch (error) {
842
+ next(error);
843
+ }
844
+ });
845
+
846
+ // Landing pages - O2VEND uses /page/:handle for CMS landing pages
847
+ // These are HTML pages created in CMS and rendered via shopfront API
848
+ this.app.get('/page/:handle', async (req, res, next) => {
849
+ try {
850
+ const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
851
+
852
+ // Load page content from shopfront API (mock)
853
+ try {
854
+ const axios = require('axios');
855
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/shopfront/api/v2/page/${req.params.handle}`);
856
+ if (pageResponse.data) {
857
+ context.page = {
858
+ ...context.page,
859
+ ...pageResponse.data,
860
+ body_html: pageResponse.data.content || pageResponse.data.htmlContent,
861
+ content: pageResponse.data.content || pageResponse.data.htmlContent
862
+ };
863
+ }
864
+ } catch (error) {
865
+ console.warn(`[PAGE] Failed to load landing page for ${req.params.handle}:`, error.message);
866
+ // Fallback to mock API pages endpoint
867
+ try {
868
+ const axios = require('axios');
869
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
870
+ if (pageResponse.data) {
871
+ context.page = {
872
+ ...context.page,
873
+ ...pageResponse.data,
874
+ body_html: pageResponse.data.content,
875
+ content: pageResponse.data.content
876
+ };
877
+ }
878
+ } catch (fallbackError) {
879
+ context.page = {
880
+ ...context.page,
881
+ title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
882
+ handle: req.params.handle,
883
+ body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`,
884
+ content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Landing page content coming soon.</p>`
885
+ };
886
+ }
887
+ }
888
+
889
+ // Try page.liquid template first, then fallback to rendering raw HTML
890
+ try {
891
+ const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
892
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
893
+ res.send(html);
894
+ } catch (templateError) {
895
+ // If no page template, render raw HTML content within layout
896
+ console.log(`[PAGE] No page template found, rendering raw HTML for ${req.params.handle}`);
897
+ const html = this.renderLandingPage(context);
898
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
899
+ res.send(html);
900
+ }
901
+ } catch (error) {
902
+ next(error);
903
+ }
904
+ });
905
+
906
+ // Also support /pages/:handle for backward compatibility
907
+ this.app.get('/pages/:handle', async (req, res, next) => {
908
+ try {
909
+ const context = await this.buildContext(req, 'page', { pageHandle: req.params.handle });
910
+
911
+ // Load page content from mock API
912
+ try {
913
+ const axios = require('axios');
914
+ const pageResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/pages/${req.params.handle}`);
915
+ if (pageResponse.data) {
916
+ context.page = {
917
+ ...context.page,
918
+ ...pageResponse.data,
919
+ body_html: pageResponse.data.content,
920
+ content: pageResponse.data.content
921
+ };
922
+ }
923
+ } catch (error) {
924
+ console.warn(`[PAGE] Failed to load page content for ${req.params.handle}:`, error.message);
925
+ // Set default page content
926
+ context.page = {
927
+ ...context.page,
928
+ title: req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' '),
929
+ handle: req.params.handle,
930
+ body_html: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`,
931
+ content: `<h2>${req.params.handle.charAt(0).toUpperCase() + req.params.handle.slice(1).replace(/-/g, ' ')}</h2><p>Page content coming soon.</p>`
932
+ };
933
+ }
934
+
935
+ const html = await renderWithLayout(this.liquid, 'templates/page', context, this.themePath);
936
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
937
+ res.send(html);
938
+ } catch (error) {
939
+ next(error);
940
+ }
941
+ });
942
+
943
+ // Development Dashboard - List all theme components
944
+ this.app.get('/dev', async (req, res, next) => {
945
+ try {
946
+ const templates = this.getThemeFiles('templates');
947
+ const sections = this.getThemeFiles('sections');
948
+ const widgets = this.getThemeFiles('widgets');
949
+ const snippets = this.getThemeFiles('snippets');
950
+
951
+ const html = this.generateDevDashboard({ templates, sections, widgets, snippets });
952
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
953
+ res.send(html);
954
+ } catch (error) {
955
+ next(error);
956
+ }
957
+ });
958
+
959
+ // Preview Section
960
+ this.app.get('/dev/section/:name', async (req, res, next) => {
961
+ try {
962
+ const sectionName = req.params.name.replace(/\.liquid$/, '');
963
+ const context = await this.buildContext(req, 'home');
964
+ const sectionContent = await this.liquid.renderFile(
965
+ path.join(this.themePath, 'sections', `${sectionName}.liquid`),
966
+ context
967
+ );
968
+
969
+ const html = this.generatePreviewPage({
970
+ title: `Section: ${sectionName}`,
971
+ content: sectionContent,
972
+ type: 'section',
973
+ name: sectionName,
974
+ backUrl: '/dev'
975
+ });
976
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
977
+ res.send(html);
978
+ } catch (error) {
979
+ next(error);
980
+ }
981
+ });
982
+
983
+ // Preview Widget
984
+ this.app.get('/dev/widget/:type', async (req, res, next) => {
985
+ try {
986
+ const widgetType = req.params.type.replace(/\.liquid$/, '');
987
+ const context = await this.buildContext(req, 'home');
988
+
989
+ // Ensure products are loaded for widget context (needed for product widgets)
990
+ if (!context.products || context.products.length === 0) {
991
+ try {
992
+ if (this.apiClient) {
993
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
994
+ context.products = productsResponse.products || productsResponse.data?.products || [];
995
+ console.log(`[DEV WIDGET] Loaded ${context.products.length} products for widget context`);
996
+ }
997
+ } catch (error) {
998
+ console.warn('[DEV WIDGET] Failed to load products:', error.message);
999
+ }
1000
+ }
1001
+
1002
+ // Get mock widget data for this type
1003
+ const mockWidgets = this.mockApi?.mockData?.widgets || [];
1004
+ const mockWidget = mockWidgets.find(w =>
1005
+ w.type === widgetType ||
1006
+ w.type.toLowerCase() === widgetType.toLowerCase() ||
1007
+ w.id?.includes(widgetType)
1008
+ ) || {
1009
+ id: `widget-${widgetType}-1`,
1010
+ type: widgetType,
1011
+ section: 'content',
1012
+ sectionName: 'content',
1013
+ status: 'active',
1014
+ position: 1,
1015
+ title: `Test ${widgetType}`,
1016
+ settings: {},
1017
+ data: { content: {} }
1018
+ };
1019
+
1020
+ // Normalize widget using widget service if available
1021
+ let widgetToRender = mockWidget;
1022
+ if (this.widgetService) {
1023
+ widgetToRender = this.widgetService.normalizeWidget(mockWidget);
1024
+ }
1025
+
1026
+ const widgetContent = await this.renderWidget(widgetToRender, context);
1027
+
1028
+ const html = this.generatePreviewPage({
1029
+ title: `Widget: ${widgetType}`,
1030
+ content: widgetContent,
1031
+ type: 'widget',
1032
+ name: widgetType,
1033
+ backUrl: '/dev'
1034
+ });
1035
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1036
+ res.send(html);
1037
+ } catch (error) {
1038
+ next(error);
1039
+ }
1040
+ });
1041
+
1042
+ // Preview Snippet
1043
+ this.app.get('/dev/snippet/:name', async (req, res, next) => {
1044
+ try {
1045
+ let snippetName = req.params.name;
1046
+
1047
+ // Handle URL encoding and object string issues
1048
+ if (snippetName.includes('[object') || snippetName.includes('%5Bobject')) {
1049
+ return res.status(400).send('Invalid snippet name. Please use the dashboard to navigate to snippets.');
1050
+ }
1051
+
1052
+ snippetName = decodeURIComponent(snippetName).replace(/\.liquid$/, '');
1053
+
1054
+ // Check if snippet exists
1055
+ const snippetPath = path.join(this.themePath, 'snippets', `${snippetName}.liquid`);
1056
+ if (!fs.existsSync(snippetPath)) {
1057
+ return res.status(404).send(`Snippet not found: ${snippetName}.liquid`);
1058
+ }
1059
+
1060
+ const context = await this.buildContext(req, 'home');
1061
+
1062
+ // Try to render snippet with common variables
1063
+ const snippetContext = {
1064
+ ...context,
1065
+ snippet: {
1066
+ name: snippetName
1067
+ },
1068
+ // Add common snippet variables
1069
+ product: context.products?.[0] || context.product,
1070
+ collection: context.collections?.[0] || context.collection,
1071
+ cart: context.cart
1072
+ };
1073
+
1074
+ const snippetContent = await this.liquid.renderFile(snippetPath, snippetContext);
1075
+
1076
+ const html = this.generatePreviewPage({
1077
+ title: `Snippet: ${snippetName}`,
1078
+ content: snippetContent,
1079
+ type: 'snippet',
1080
+ name: snippetName,
1081
+ backUrl: '/dev'
1082
+ });
1083
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1084
+ res.send(html);
1085
+ } catch (error) {
1086
+ next(error);
1087
+ }
1088
+ });
1089
+
1090
+ // View Template
1091
+ this.app.get('/dev/template/*', async (req, res, next) => {
1092
+ try {
1093
+ const templatePath = req.params[0] || req.params.name;
1094
+ const templateName = templatePath.replace(/\.liquid$/, '');
1095
+
1096
+ // Check if it's a subdirectory template (e.g., account/dashboard)
1097
+ let fullPath = path.join(this.themePath, 'templates', `${templateName}.liquid`);
1098
+ if (!fs.existsSync(fullPath)) {
1099
+ // Try as subdirectory with index.liquid
1100
+ const subPath = path.join(this.themePath, 'templates', templateName, 'index.liquid');
1101
+ if (fs.existsSync(subPath)) {
1102
+ fullPath = subPath;
1103
+ } else {
1104
+ return res.status(404).send(`Template not found: ${templateName}`);
1105
+ }
1106
+ }
1107
+
1108
+ // Build appropriate context based on template type
1109
+ let pageType = 'home';
1110
+ if (templateName.includes('product')) pageType = 'product';
1111
+ else if (templateName.includes('collection') || templateName.includes('categor')) pageType = 'collection';
1112
+ else if (templateName.includes('cart')) pageType = 'cart';
1113
+ else if (templateName.includes('search')) pageType = 'search';
1114
+ else if (templateName.includes('page')) pageType = 'page';
1115
+ else if (templateName.includes('account') || templateName.includes('login')) pageType = 'page';
1116
+ else if (templateName.includes('checkout')) pageType = 'cart';
1117
+
1118
+ const context = await this.buildContext(req, pageType);
1119
+ const html = await renderWithLayout(this.liquid, `templates/${templateName}`, context, this.themePath);
1120
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1121
+ res.send(html);
1122
+ } catch (error) {
1123
+ next(error);
1124
+ }
1125
+ });
1126
+
1127
+ // Favicon handler
1128
+ this.app.get('/favicon.ico', (req, res) => {
1129
+ res.status(204).end(); // No Content
1130
+ });
1131
+
1132
+ // API proxy (for real API mode)
1133
+ if (this.mode === 'real' && this.apiClient) {
1134
+ this.app.use('/api', (req, res, next) => {
1135
+ // Proxy API requests to real API
1136
+ // This is handled by mock API in mock mode
1137
+ next();
1138
+ });
1139
+ }
1140
+
1141
+ // Error handler
1142
+ this.app.use((error, req, res, next) => {
1143
+ // Ignore request aborted errors - they're harmless (client cancelled request)
1144
+ if (error && (error.code === 'ECONNRESET' ||
1145
+ error.message === 'request aborted' ||
1146
+ error.message?.includes('aborted') ||
1147
+ error.type === 'entity.parse.failed' ||
1148
+ error.name === 'BadRequestError')) {
1149
+ // Request was aborted - don't log as error
1150
+ if (!res.headersSent) {
1151
+ res.status(400).end();
1152
+ }
1153
+ return;
1154
+ }
1155
+
1156
+ console.error(chalk.red('[ERROR]'), error.message || error);
1157
+ if (process.env.DEBUG) {
1158
+ console.error(error.stack);
1159
+ }
1160
+
1161
+ // Return JSON for API/asset requests, HTML for page requests
1162
+ const isApiRequest = req.path.startsWith('/api') ||
1163
+ req.path.startsWith('/webstoreapi') ||
1164
+ req.path.startsWith('/assets') ||
1165
+ req.path.startsWith('/shopfront/api');
1166
+
1167
+ if (isApiRequest && !res.headersSent) {
1168
+ res.status(500).json({
1169
+ error: error.message || 'Internal server error',
1170
+ path: req.path
1171
+ });
1172
+ return;
1173
+ }
1174
+
1175
+ if (!res.headersSent) {
1176
+ res.status(500).send(`
1177
+ <!DOCTYPE html>
1178
+ <html>
1179
+ <head>
1180
+ <title>Error</title>
1181
+ <style>
1182
+ body { font-family: monospace; padding: 2rem; }
1183
+ h1 { color: #d32f2f; }
1184
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; }
1185
+ </style>
1186
+ </head>
1187
+ <body>
1188
+ <h1>Error Rendering Page</h1>
1189
+ <pre>${error.message}\n\n${error.stack}</pre>
1190
+ </body>
1191
+ </html>
1192
+ `);
1193
+ }
1194
+ });
1195
+ }
1196
+
1197
+ /**
1198
+ * Build template context
1199
+ *
1200
+ * IMPORTANT NOTES ABOUT DATA FLOW:
1201
+ *
1202
+ * 1. PRODUCTS WITH QUANTITY/STOCK:
1203
+ * - Mock data generates products with 'stock' field (see mock-data.js)
1204
+ * - Products also have variants with 'stock' field
1205
+ * - The API client normalizes keys (PascalCase -> camelCase) but 'stock' is already lowercase
1206
+ * - Products should maintain 'stock' field when passed to templates
1207
+ * - If template expects 'quantity' instead of 'stock', consider adding both fields
1208
+ *
1209
+ * 2. WIDGETS LOADING:
1210
+ * - Widgets are loaded via widgetService.getWidgetsBySections()
1211
+ * - Widgets are organized by section (header, hero, content, footer)
1212
+ * - Widgets should be available for ALL page types (home, products, collection, etc.)
1213
+ * - If widgets are missing, check:
1214
+ * a) widgetService is initialized (should be created in setupServices)
1215
+ * b) Mock API is returning widgets correctly
1216
+ * c) Widget service is properly organizing widgets by section
1217
+ *
1218
+ * 3. MENUS LOADING:
1219
+ * - Menus are loaded from /webstoreapi/menus endpoint
1220
+ * - Menus should be available for navigation in header/footer sections
1221
+ * - If menus are missing, check:
1222
+ * a) Mock API is running and responding to /webstoreapi/menus
1223
+ * b) Menu data structure matches expected format
1224
+ *
1225
+ * @param {Object} req - Express request
1226
+ * @param {string} pageType - Page type (home, product, collection, etc.)
1227
+ * @param {Object} extra - Extra context data
1228
+ * @returns {Promise<Object>} Template context
1229
+ */
1230
+ async buildContext(req, pageType, extra = {}) {
1231
+ const context = {
1232
+ shop: {},
1233
+ tenant: {},
1234
+ page: {
1235
+ type: pageType,
1236
+ ...extra
1237
+ },
1238
+ products: [],
1239
+ collections: [],
1240
+ categories: [],
1241
+ brands: [],
1242
+ cart: {
1243
+ id: 'mock-cart-1',
1244
+ items: [],
1245
+ total: 0,
1246
+ itemCount: 0,
1247
+ currency: 'USD',
1248
+ subtotal: 0,
1249
+ tax: 0,
1250
+ shipping: 0
1251
+ },
1252
+ // Mock authenticated customer for dev mode (allows add-to-cart without login)
1253
+ customer: {
1254
+ id: 'mock-customer-1',
1255
+ isAuthenticated: true,
1256
+ firstName: 'Test',
1257
+ lastName: 'User',
1258
+ email: 'test@example.com',
1259
+ phone: '+1234567890',
1260
+ name: 'Test User'
1261
+ },
1262
+ widgets: {},
1263
+ settings: {},
1264
+ menus: [],
1265
+ ...extra
1266
+ };
1267
+
1268
+ try {
1269
+ // Load settings from settings_data.json
1270
+ const settingsPath = path.join(this.themePath, 'config', 'settings_data.json');
1271
+ if (fs.existsSync(settingsPath)) {
1272
+ const settingsData = fs.readFileSync(settingsPath, 'utf8');
1273
+ context.settings = JSON.parse(settingsData);
1274
+ }
1275
+ } catch (error) {
1276
+ console.warn('[CONTEXT] Failed to load settings:', error.message);
1277
+ }
1278
+
1279
+ // Load data based on mode
1280
+ // Note: In mock mode, apiClient is created in start() method after mock API starts
1281
+ if (this.mode === 'mock' && this.apiClient) {
1282
+ // Widget service should be available, but continue even if it's not
1283
+ if (!this.widgetService) {
1284
+ console.warn('[CONTEXT] Widget service not initialized yet in mock mode');
1285
+ }
1286
+ // Use mock API (API client points to mock API via proxy)
1287
+ try {
1288
+ const storeInfo = await this.apiClient.getStoreInfo(true);
1289
+ context.shop = storeInfo || {
1290
+ name: 'My Store',
1291
+ description: 'O2VEND Store',
1292
+ domain: 'localhost:3000',
1293
+ email: 'store@example.com'
1294
+ };
1295
+
1296
+ // Get widgets for sections from mock API
1297
+ // NOTE: Widgets should be loaded for ALL page types (home, products, collection, etc.)
1298
+ // The widget service filters widgets by section, so all sections should be available
1299
+ if (this.widgetService) {
1300
+ try {
1301
+ console.log(`[CONTEXT] Loading widgets for page type: ${pageType}`);
1302
+ const widgets = await this.widgetService.getWidgetsBySections();
1303
+ context.widgets = widgets || {
1304
+ hero: [],
1305
+ products: [],
1306
+ footer: [],
1307
+ content: [],
1308
+ header: []
1309
+ };
1310
+ // Log widget counts by section
1311
+ const widgetCounts = Object.keys(context.widgets).map(section =>
1312
+ `${section}: ${context.widgets[section]?.length || 0}`
1313
+ ).join(', ');
1314
+ console.log(`[CONTEXT] āœ… Widgets loaded: ${widgetCounts}`);
1315
+
1316
+ // DEBUG: Log detailed widget information
1317
+ Object.keys(context.widgets).forEach(section => {
1318
+ if (context.widgets[section] && context.widgets[section].length > 0) {
1319
+ console.log(`[CONTEXT DEBUG] Section '${section}' has ${context.widgets[section].length} widget(s):`);
1320
+ context.widgets[section].slice(0, 3).forEach((w, idx) => {
1321
+ console.log(` - Widget ${idx + 1}: ${w.type || 'Unknown'} (ID: ${w.id || 'no-id'})`);
1322
+ });
1323
+ }
1324
+ });
1325
+ } catch (error) {
1326
+ console.error('[CONTEXT] āŒ Failed to load widgets:', error.message);
1327
+ console.error('[CONTEXT] Error stack:', error.stack);
1328
+ context.widgets = {
1329
+ hero: [],
1330
+ products: [],
1331
+ footer: [],
1332
+ content: [],
1333
+ header: []
1334
+ };
1335
+ }
1336
+ } else {
1337
+ console.warn('[CONTEXT] āš ļø Widget service not available, using empty widgets');
1338
+ console.warn('[CONTEXT] This may happen if widgetService was not initialized properly');
1339
+ context.widgets = {
1340
+ hero: [],
1341
+ products: [],
1342
+ footer: [],
1343
+ content: [],
1344
+ header: []
1345
+ };
1346
+ }
1347
+
1348
+ // Always load products, categories, brands, menus, and cart for navigation and widgets
1349
+ try {
1350
+ // Load products
1351
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1352
+ context.products = productsResponse.products || productsResponse.data?.products || [];
1353
+ console.log(`[CONTEXT] Loaded ${context.products.length} products`);
1354
+
1355
+ // DEBUG: Verify products have quantity/stock field
1356
+ if (context.products.length > 0) {
1357
+ const firstProduct = context.products[0];
1358
+ const hasStock = 'stock' in firstProduct || 'quantity' in firstProduct;
1359
+ // Use nullish coalescing to show 0 values correctly (0 || 'N/A' would show N/A incorrectly)
1360
+ const stockValue = firstProduct.stock ?? firstProduct.quantity ?? 'N/A';
1361
+ console.log(`[CONTEXT DEBUG] First product sample - Has stock field: ${hasStock}, Stock value: ${stockValue}`);
1362
+ console.log(`[CONTEXT DEBUG] First product keys: ${Object.keys(firstProduct).join(', ')}`);
1363
+ console.log(`[CONTEXT DEBUG] First product stock details: stock=${firstProduct.stock}, quantity=${firstProduct.quantity}, inStock=${firstProduct.inStock}`);
1364
+
1365
+ // Check if variants have stock
1366
+ if (firstProduct.variants && firstProduct.variants.length > 0) {
1367
+ const firstVariant = firstProduct.variants[0];
1368
+ const variantHasStock = 'stock' in firstVariant || 'quantity' in firstVariant;
1369
+ const variantStockValue = firstVariant.stock ?? firstVariant.quantity ?? 'N/A';
1370
+ console.log(`[CONTEXT DEBUG] First variant sample - Has stock field: ${variantHasStock}, Stock value: ${variantStockValue}`);
1371
+ console.log(`[CONTEXT DEBUG] First variant stock details: stock=${firstVariant.stock}, quantity=${firstVariant.quantity}, inStock=${firstVariant.inStock}, available=${firstVariant.available}`);
1372
+ }
1373
+ }
1374
+
1375
+ // Load categories for navigation
1376
+ try {
1377
+ const categoriesResponse = await this.apiClient.getCategories({ limit: 50, offset: 0 });
1378
+ context.categories = categoriesResponse.categories || categoriesResponse.data?.categories || categoriesResponse || [];
1379
+ context.collections = context.categories; // Also set collections for compatibility
1380
+ console.log(`[CONTEXT] Loaded ${context.categories.length} categories`);
1381
+ } catch (error) {
1382
+ console.warn('[CONTEXT] Failed to load categories:', error.message);
1383
+ context.categories = [];
1384
+ context.collections = [];
1385
+ }
1386
+
1387
+ // Load brands for navigation
1388
+ try {
1389
+ const brandsResponse = await this.apiClient.getBrands({ limit: 50, offset: 0 });
1390
+ context.brands = brandsResponse.brands || brandsResponse.data?.brands || brandsResponse || [];
1391
+ console.log(`[CONTEXT] Loaded ${context.brands.length} brands`);
1392
+ } catch (error) {
1393
+ console.warn('[CONTEXT] Failed to load brands:', error.message);
1394
+ context.brands = [];
1395
+ }
1396
+
1397
+ // Load menus (via webstoreapi) - use axios from apiClient
1398
+ // NOTE: Menus are loaded for navigation in header/footer sections
1399
+ // They should be available in context for all page types
1400
+ try {
1401
+ const axios = require('axios');
1402
+ console.log(`[CONTEXT] Loading menus from http://localhost:${this.mockApiPort}/webstoreapi/menus`);
1403
+ const menusResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/menus`);
1404
+ const menusData = menusResponse.data;
1405
+ context.menus = Array.isArray(menusData) ? menusData : (menusData.data?.menus || menusData.menus || []);
1406
+ console.log(`[CONTEXT] āœ… Loaded ${context.menus.length} menus`);
1407
+
1408
+ // DEBUG: Log menu details
1409
+ if (context.menus.length > 0) {
1410
+ context.menus.forEach((menu, idx) => {
1411
+ const itemCount = menu.items?.length || 0;
1412
+ console.log(`[CONTEXT DEBUG] Menu ${idx + 1}: ${menu.name || menu.id || 'unnamed'} (${itemCount} items)`);
1413
+ });
1414
+ } else {
1415
+ console.warn('[CONTEXT DEBUG] āš ļø No menus loaded - menus array is empty');
1416
+ }
1417
+ } catch (error) {
1418
+ console.error('[CONTEXT] āŒ Failed to load menus:', error.message);
1419
+ console.error('[CONTEXT] Error details:', error.response?.status, error.response?.data);
1420
+ context.menus = [];
1421
+ }
1422
+
1423
+ // Load cart data
1424
+ try {
1425
+ const axios = require('axios');
1426
+ const cartResponse = await axios.get(`http://localhost:${this.mockApiPort}/webstoreapi/carts`);
1427
+ if (cartResponse.data && cartResponse.data.data) {
1428
+ const cartData = cartResponse.data.data;
1429
+ context.cart = {
1430
+ ...context.cart,
1431
+ ...cartData,
1432
+ itemCount: cartData.items?.length || cartData.itemCount || 0,
1433
+ total: cartData.total || cartData.subTotal || 0
1434
+ };
1435
+ }
1436
+ } catch (error) {
1437
+ // Cart might not exist yet, that's okay - use default empty cart
1438
+ console.log('[CONTEXT] Using default empty cart');
1439
+ }
1440
+ } catch (error) {
1441
+ console.warn('[CONTEXT] Failed to load data:', error.message);
1442
+ context.products = [];
1443
+ }
1444
+
1445
+ // Load page-specific data
1446
+ if (pageType === 'product' && extra.productHandle) {
1447
+ // Load single product data
1448
+ try {
1449
+ // First try to find in already loaded products
1450
+ let product = context.products?.find(p =>
1451
+ (p.handle && p.handle.toLowerCase() === extra.productHandle.toLowerCase()) ||
1452
+ (p.slug && p.slug.toLowerCase() === extra.productHandle.toLowerCase()) ||
1453
+ (p.id && String(p.id).toLowerCase() === extra.productHandle.toLowerCase())
1454
+ );
1455
+
1456
+ // If not found, try loading more products
1457
+ if (!product) {
1458
+ const productsResponse = await this.apiClient.getProducts({ limit: 100 });
1459
+ const allProducts = productsResponse.products || productsResponse.data?.products || [];
1460
+ product = allProducts.find(p =>
1461
+ (p.handle && p.handle.toLowerCase() === extra.productHandle.toLowerCase()) ||
1462
+ (p.slug && p.slug.toLowerCase() === extra.productHandle.toLowerCase()) ||
1463
+ (p.id && String(p.id).toLowerCase() === extra.productHandle.toLowerCase())
1464
+ );
1465
+ }
1466
+
1467
+ if (product) {
1468
+ context.product = product;
1469
+ console.log(`[CONTEXT] Found product: ${product.title || product.name} (handle: ${product.handle || product.slug})`);
1470
+ } else {
1471
+ console.warn(`[CONTEXT] Product not found with handle/slug/id: ${extra.productHandle}`);
1472
+ // Fallback to first product if handle not found
1473
+ if (context.products && context.products.length > 0) {
1474
+ context.product = context.products[0];
1475
+ console.log(`[CONTEXT] Using fallback product: ${context.product.title || context.product.name}`);
1476
+ }
1477
+ }
1478
+ } catch (error) {
1479
+ console.warn('[CONTEXT] Failed to load product:', error.message);
1480
+ if (context.products && context.products.length > 0) {
1481
+ context.product = context.products[0];
1482
+ }
1483
+ }
1484
+ }
1485
+ } catch (error) {
1486
+ console.warn('[CONTEXT] Failed to load from mock API:', error.message);
1487
+ // Fallback to default values
1488
+ context.shop = {
1489
+ name: 'My Store',
1490
+ description: 'O2VEND Store',
1491
+ domain: 'localhost:3000',
1492
+ email: 'store@example.com'
1493
+ };
1494
+ context.widgets = {
1495
+ hero: [],
1496
+ products: [],
1497
+ footer: [],
1498
+ content: []
1499
+ };
1500
+ }
1501
+ } else if (this.apiClient) {
1502
+ // Use real API
1503
+ try {
1504
+ const storeInfo = await this.apiClient.getStoreInfo(true);
1505
+ context.shop = storeInfo;
1506
+ context.tenant = { id: this.apiClient.tenantId };
1507
+
1508
+ // Get widgets for sections
1509
+ const widgets = await this.widgetService.getWidgetsBySections();
1510
+ context.widgets = widgets;
1511
+
1512
+ // Ensure all widgets have template_path set for rendering
1513
+ Object.keys(widgets).forEach(section => {
1514
+ if (Array.isArray(widgets[section])) {
1515
+ widgets[section].forEach(widget => {
1516
+ if (!widget.template_path && widget.template) {
1517
+ widget.template_path = `widgets/${widget.template}`;
1518
+ }
1519
+ if (!widget.template_path && widget.type) {
1520
+ const templateSlug = this.widgetService.getTemplateSlug(widget.type);
1521
+ widget.template_path = `widgets/${templateSlug}`;
1522
+ widget.template = templateSlug;
1523
+ }
1524
+ });
1525
+ }
1526
+ });
1527
+
1528
+ // Load page-specific data
1529
+ if (pageType === 'products' || pageType === 'home') {
1530
+ // Load products for products listing page or homepage
1531
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1532
+ context.products = productsResponse.products || [];
1533
+ // For products page, also set collection.products
1534
+ if (pageType === 'products') {
1535
+ context.collection = context.collection || {};
1536
+ context.collection.products = context.products;
1537
+ context.collection.title = 'All Products';
1538
+ context.collection.handle = 'all';
1539
+ context.collection.totalProducts = context.products.length;
1540
+ }
1541
+ } else if (pageType === 'product' && extra.productHandle) {
1542
+ // Load product data
1543
+ // context.product = await this.loadProduct(extra.productHandle);
1544
+ } else if (pageType === 'collection') {
1545
+ // Load products for collection
1546
+ const productsResponse = await this.apiClient.getProducts({ limit: 50, offset: 0 });
1547
+ context.products = productsResponse.products || [];
1548
+ // Set collection.products for collection pages
1549
+ context.collection = context.collection || {};
1550
+ context.collection.products = context.products;
1551
+ }
1552
+ } catch (error) {
1553
+ console.warn('[CONTEXT] Failed to load from API:', error.message);
1554
+ }
1555
+ }
1556
+
1557
+ // Enrich widgets with products, categories, and brands data
1558
+ this.enrichWidgetsWithData(context);
1559
+
1560
+ return context;
1561
+ }
1562
+
1563
+ /**
1564
+ * Enrich widgets with actual data (products, categories, brands)
1565
+ * This is essential for product carousels, category lists, etc. to display content
1566
+ * Widget templates expect data in widget.data.products (not widget.data.content.Products)
1567
+ */
1568
+ enrichWidgetsWithData(context) {
1569
+ if (!context.widgets) return;
1570
+
1571
+ const products = context.products || [];
1572
+ const categories = context.categories || [];
1573
+ const brands = context.brands || [];
1574
+
1575
+ let enrichedCount = 0;
1576
+
1577
+ // Process all widgets in all sections
1578
+ Object.keys(context.widgets).forEach(section => {
1579
+ if (!Array.isArray(context.widgets[section])) return;
1580
+
1581
+ context.widgets[section].forEach(widget => {
1582
+ const type = (widget.type || '').toLowerCase();
1583
+
1584
+ // Initialize widget.data if not present
1585
+ widget.data = widget.data || {};
1586
+
1587
+ // Enrich ProductCarousel, ProductGrid, and Product widgets with products
1588
+ // Widget templates use: widget.data.products, widget_data.products
1589
+ if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
1590
+ const widgetProducts = this.getProductsForWidget(widget, products);
1591
+
1592
+ // Set products at multiple levels for template compatibility
1593
+ widget.data.products = widgetProducts;
1594
+ widget.data.Products = widgetProducts;
1595
+ widget.products = widgetProducts;
1596
+
1597
+ // Also set in content for some template variations
1598
+ widget.data.content = widget.data.content || {};
1599
+ widget.data.content.products = widgetProducts;
1600
+ widget.data.content.Products = widgetProducts;
1601
+
1602
+ enrichedCount++;
1603
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${widgetProducts.length} products`);
1604
+ }
1605
+
1606
+ // Enrich CategoryList and CategoryListCarousel widgets with categories
1607
+ // Widget templates use: widget.data.categories, widget.categories
1608
+ if (type.includes('category') || type === 'categorylist' || type === 'categorylistcarousel') {
1609
+ const widgetCategories = categories.slice(0, 10);
1610
+
1611
+ // Set categories at multiple levels
1612
+ widget.data.categories = widgetCategories;
1613
+ widget.data.Categories = widgetCategories;
1614
+ widget.categories = widgetCategories;
1615
+
1616
+ widget.data.content = widget.data.content || {};
1617
+ widget.data.content.categories = widgetCategories;
1618
+ widget.data.content.Categories = widgetCategories;
1619
+
1620
+ enrichedCount++;
1621
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${widgetCategories.length} categories`);
1622
+ }
1623
+
1624
+ // Enrich BrandCarousel and Brand widgets with brands
1625
+ if (type.includes('brand') || type === 'brandcarousel' || type === 'brandlist') {
1626
+ widget.data.brands = brands;
1627
+ widget.data.Brands = brands;
1628
+ widget.brands = brands;
1629
+
1630
+ widget.data.content = widget.data.content || {};
1631
+ widget.data.content.brands = brands;
1632
+ widget.data.content.Brands = brands;
1633
+
1634
+ enrichedCount++;
1635
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${brands.length} brands`);
1636
+ }
1637
+
1638
+ // Enrich RecentlyViewed widgets with products (use first 6 products as mock recent views)
1639
+ if (type === 'recentlyviewed' || type === 'recently-viewed') {
1640
+ const recentProducts = products.slice(0, 6);
1641
+ widget.data.products = recentProducts;
1642
+ widget.data.Products = recentProducts;
1643
+ widget.products = recentProducts;
1644
+
1645
+ enrichedCount++;
1646
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
1647
+ }
1648
+ });
1649
+ });
1650
+
1651
+ console.log(`[CONTEXT] āœ… Enriched ${enrichedCount} widgets with data`);
1652
+ }
1653
+
1654
+ /**
1655
+ * Get products for a specific widget based on its settings
1656
+ */
1657
+ getProductsForWidget(widget, products) {
1658
+ // Get the limit from widget settings, default to 12
1659
+ const limit = widget.settings?.limit || widget.data?.content?.Limit || 12;
1660
+
1661
+ // Get products based on widget type
1662
+ const type = (widget.type || '').toLowerCase();
1663
+
1664
+ if (type.includes('featured') || type === 'featuredproducts') {
1665
+ // Featured products - filter by featured tag or just take first N
1666
+ const featured = products.filter(p => p.tags?.includes('featured') || p.featured);
1667
+ return featured.length > 0 ? featured.slice(0, limit) : products.slice(0, limit);
1668
+ }
1669
+
1670
+ if (type.includes('bestseller') || type === 'bestsellerproducts') {
1671
+ // Best seller products - sort by sales or just take random N
1672
+ return products.slice(0, limit);
1673
+ }
1674
+
1675
+ if (type.includes('new') || type === 'newproducts') {
1676
+ // New products - sort by created date
1677
+ const sorted = [...products].sort((a, b) =>
1678
+ new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
1679
+ );
1680
+ return sorted.slice(0, limit);
1681
+ }
1682
+
1683
+ // Default: return first N products
1684
+ return products.slice(0, limit);
1685
+ }
1686
+
1687
+ /**
1688
+ * Create a simple brands listing page (fallback)
1689
+ */
1690
+ createSimpleBrandsPage(context) {
1691
+ const brands = context.brands || [];
1692
+ const brandCards = brands.map(brand => `
1693
+ <div style="background: #fff; border-radius: 8px; padding: 1rem; text-align: center; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
1694
+ <a href="/brands/${brand.handle || brand.name?.toLowerCase().replace(/\s+/g, '-') || brand.id}">
1695
+ <img src="${brand.image || brand.imageUrl || 'https://picsum.photos/seed/' + brand.id + '/200/100'}"
1696
+ alt="${brand.name}"
1697
+ style="max-width: 150px; height: auto; margin-bottom: 0.5rem;">
1698
+ <h3 style="margin: 0.5rem 0; color: #333;">${brand.name}</h3>
1699
+ </a>
1700
+ </div>
1701
+ `).join('');
1702
+
1703
+ return `
1704
+ <!DOCTYPE html>
1705
+ <html>
1706
+ <head>
1707
+ <title>Brands - ${context.shop?.name || 'Store'}</title>
1708
+ <style>
1709
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; margin: 0; padding: 2rem; }
1710
+ h1 { text-align: center; margin-bottom: 2rem; }
1711
+ .brands-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1.5rem; max-width: 1200px; margin: 0 auto; }
1712
+ </style>
1713
+ </head>
1714
+ <body>
1715
+ <h1>Brands</h1>
1716
+ <div class="brands-grid">${brandCards}</div>
1717
+ <p style="text-align: center; margin-top: 2rem;"><a href="/">← Back to Home</a></p>
1718
+ </body>
1719
+ </html>
1720
+ `;
1721
+ }
1722
+
1723
+ /**
1724
+ * Create a simple brand detail page (fallback)
1725
+ */
1726
+ createSimpleBrandPage(context, brand) {
1727
+ if (!brand) {
1728
+ return `
1729
+ <!DOCTYPE html>
1730
+ <html>
1731
+ <head><title>Brand Not Found</title></head>
1732
+ <body>
1733
+ <h1>Brand Not Found</h1>
1734
+ <p><a href="/brands">← Back to Brands</a></p>
1735
+ </body>
1736
+ </html>
1737
+ `;
1738
+ }
1739
+
1740
+ const products = context.collection?.products || [];
1741
+ const productCards = products.map(p => `
1742
+ <div style="background: #fff; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
1743
+ <a href="/products/${p.handle || p.id}">
1744
+ <img src="${p.images?.[0] || p.imageUrl || 'https://picsum.photos/seed/' + p.id + '/300/300'}"
1745
+ alt="${p.title || p.name}" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;">
1746
+ <h4 style="margin: 0.5rem 0;">${p.title || p.name}</h4>
1747
+ <p style="color: #2563eb; font-weight: bold;">$${((p.price || 0) / 100).toFixed(2)}</p>
1748
+ </a>
1749
+ </div>
1750
+ `).join('');
1751
+
1752
+ return `
1753
+ <!DOCTYPE html>
1754
+ <html>
1755
+ <head>
1756
+ <title>${brand.name} - ${context.shop?.name || 'Store'}</title>
1757
+ <style>
1758
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; margin: 0; padding: 2rem; }
1759
+ h1 { text-align: center; }
1760
+ .products-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 1.5rem; max-width: 1200px; margin: 0 auto; }
1761
+ </style>
1762
+ </head>
1763
+ <body>
1764
+ <h1>${brand.name}</h1>
1765
+ <p style="text-align: center;">${brand.description || ''}</p>
1766
+ ${products.length > 0 ? `<div class="products-grid">${productCards}</div>` : '<p style="text-align: center;">No products found for this brand.</p>'}
1767
+ <p style="text-align: center; margin-top: 2rem;"><a href="/brands">← Back to Brands</a></p>
1768
+ </body>
1769
+ </html>
1770
+ `;
1771
+ }
1772
+
1773
+ /**
1774
+ * Render a CMS landing page with the theme's layout
1775
+ * Landing pages are HTML content from CMS rendered within the theme layout
1776
+ */
1777
+ renderLandingPage(context) {
1778
+ const page = context.page || {};
1779
+ const htmlContent = page.body_html || page.htmlContent || page.content || '<p>Page content not available.</p>';
1780
+ const title = page.title || 'Page';
1781
+
1782
+ return `
1783
+ <!DOCTYPE html>
1784
+ <html lang="en">
1785
+ <head>
1786
+ <meta charset="UTF-8">
1787
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1788
+ <title>${title} - ${context.shop?.name || 'Store'}</title>
1789
+ <meta name="description" content="${page.metaDescription || ''}">
1790
+ <style>
1791
+ * { margin: 0; padding: 0; box-sizing: border-box; }
1792
+ body {
1793
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
1794
+ background: #fff;
1795
+ color: #333;
1796
+ line-height: 1.6;
1797
+ }
1798
+ .container { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
1799
+ .landing-page { padding: 40px 0; min-height: 60vh; }
1800
+ .page-header {
1801
+ background: #f8f9fa;
1802
+ padding: 20px 0;
1803
+ border-bottom: 1px solid #eee;
1804
+ margin-bottom: 40px;
1805
+ }
1806
+ .page-header nav { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
1807
+ .page-header nav a {
1808
+ color: #333;
1809
+ text-decoration: none;
1810
+ margin-right: 20px;
1811
+ font-weight: 500;
1812
+ }
1813
+ .page-header nav a:hover { color: #667eea; }
1814
+ .page-footer {
1815
+ background: #333;
1816
+ color: #fff;
1817
+ padding: 40px 0;
1818
+ margin-top: 60px;
1819
+ text-align: center;
1820
+ }
1821
+ @media (max-width: 768px) {
1822
+ .landing-page [style*="grid-template-columns: 1fr 1fr"] {
1823
+ grid-template-columns: 1fr !important;
1824
+ }
1825
+ .landing-page [style*="grid-template-columns: repeat(4"] {
1826
+ grid-template-columns: repeat(2, 1fr) !important;
1827
+ }
1828
+ }
1829
+ </style>
1830
+ </head>
1831
+ <body>
1832
+ <header class="page-header">
1833
+ <nav>
1834
+ <a href="/">Home</a>
1835
+ <a href="/products">Products</a>
1836
+ <a href="/categories">Categories</a>
1837
+ <a href="/page/about">About</a>
1838
+ <a href="/page/contact">Contact</a>
1839
+ </nav>
1840
+ </header>
1841
+
1842
+ <main class="landing-page">
1843
+ ${htmlContent}
1844
+ </main>
1845
+
1846
+ <footer class="page-footer">
1847
+ <div class="container">
1848
+ <p>&copy; ${new Date().getFullYear()} ${context.shop?.name || 'My Store'}. All rights reserved.</p>
1849
+ </div>
1850
+ </footer>
1851
+ </body>
1852
+ </html>
1853
+ `;
1854
+ }
1855
+
1856
+ /**
1857
+ * Load theme settings from config/settings_data.json
1858
+ * These settings are used to process CSS files with Liquid variables
1859
+ */
1860
+ loadThemeSettings() {
1861
+ try {
1862
+ const settingsPath = path.join(this.themePath, 'config', 'settings_data.json');
1863
+ if (fs.existsSync(settingsPath)) {
1864
+ const settingsData = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
1865
+ // Use 'current' settings, fallback to 'default' preset
1866
+ const settings = settingsData.current || settingsData.presets?.default || {};
1867
+ return settings;
1868
+ }
1869
+ } catch (error) {
1870
+ console.warn('[SETTINGS] Failed to load theme settings:', error.message);
1871
+ }
1872
+
1873
+ // Return default settings if file doesn't exist
1874
+ return {
1875
+ color_primary: '#2563eb',
1876
+ color_primary_light: '#3b82f6',
1877
+ color_primary_dark: '#1d4ed8',
1878
+ color_secondary: '#64748b',
1879
+ color_accent: '#f59e0b',
1880
+ color_accent_light: '#fbbf24',
1881
+ color_accent_dark: '#d97706',
1882
+ color_background: '#ffffff',
1883
+ color_surface: '#f8fafc',
1884
+ color_border: '#e2e8f0',
1885
+ color_text: '#1e293b',
1886
+ color_text_muted: '#64748b',
1887
+ color_text_light: '#94a3b8',
1888
+ color_success: '#22c55e',
1889
+ color_error: '#ef4444',
1890
+ color_warning: '#f59e0b',
1891
+ color_info: '#3b82f6',
1892
+ font_primary: 'Inter',
1893
+ font_display: 'Playfair Display',
1894
+ font_heading: 'Inter',
1895
+ font_body: 'Inter',
1896
+ font_mono: 'SF Mono',
1897
+ font_size_base: 16,
1898
+ font_size_heading: 32,
1899
+ font_weight_normal: '400',
1900
+ font_weight_medium: '500',
1901
+ font_weight_bold: '700',
1902
+ line_height_base: 1.6,
1903
+ line_height_heading: 1.2,
1904
+ letter_spacing_heading: '-0.02',
1905
+ letter_spacing_uppercase: '0.05',
1906
+ container_width: 1200,
1907
+ container_padding: 24,
1908
+ spacing_section: 64,
1909
+ spacing_large: 32,
1910
+ spacing_component: 24,
1911
+ spacing_element: 16,
1912
+ spacing_small: 8,
1913
+ spacing_xsmall: 4,
1914
+ border_radius_small: 4,
1915
+ border_radius_medium: 8,
1916
+ border_radius_large: 12,
1917
+ animation_speed: 'normal',
1918
+ enable_animations: true,
1919
+ enable_parallax: false,
1920
+ enable_hover_effects: true,
1921
+ enable_fade_in: true,
1922
+ enable_stagger_animation: true,
1923
+ shadow_opacity: 0.1,
1924
+ shadow_blur: 8,
1925
+ shadow_spread: 0,
1926
+ depth_level_1: 1,
1927
+ depth_level_2: 4,
1928
+ depth_level_3: 8,
1929
+ button_style: 'modern',
1930
+ button_padding_vertical: 12,
1931
+ button_padding_tal: 24,
1932
+ button_hover_lift: true,
1933
+ button_ripple_effect: false,
1934
+ button_transition_speed: 200,
1935
+ lazy_load_images: true,
1936
+ preload_critical_css: true,
1937
+ optimize_animations: true,
1938
+ show_loading_skeletons: true,
1939
+ loading_animation: 'spinner'
1940
+ };
1941
+ }
1942
+
1943
+ /**
1944
+ * Get theme files from a directory
1945
+ */
1946
+ getThemeFiles(directory) {
1947
+ const dirPath = path.join(this.themePath, directory);
1948
+ if (!fs.existsSync(dirPath)) {
1949
+ return [];
1950
+ }
1951
+
1952
+ const files = [];
1953
+ const scanDir = (dir, basePath = '') => {
1954
+ const items = fs.readdirSync(dir);
1955
+ items.forEach(item => {
1956
+ const itemPath = path.join(dir, item);
1957
+ const stat = fs.statSync(itemPath);
1958
+ if (stat.isDirectory()) {
1959
+ scanDir(itemPath, path.join(basePath, item));
1960
+ } else if (item.endsWith('.liquid')) {
1961
+ const name = item.replace(/\.liquid$/, '');
1962
+ const fullPath = basePath ? `${basePath}/${name}` : name;
1963
+ files.push({
1964
+ name: name,
1965
+ path: fullPath,
1966
+ fullPath: itemPath
1967
+ });
1968
+ }
1969
+ });
1970
+ };
1971
+
1972
+ scanDir(dirPath);
1973
+ return files.sort((a, b) => a.name.localeCompare(b.name));
1974
+ }
1975
+
1976
+ /**
1977
+ * Render a widget
1978
+ */
1979
+ async renderWidget(widget, context) {
1980
+ if (!widget || !widget.type) {
1981
+ return '';
1982
+ }
1983
+
1984
+ try {
1985
+ const templateSlug = this.widgetService.getTemplateSlug(widget.type);
1986
+ const widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
1987
+
1988
+ if (!fs.existsSync(widgetPath)) {
1989
+ return `<div class="error">Widget template not found: ${templateSlug}.liquid</div>`;
1990
+ }
1991
+
1992
+ const widgetContent = fs.readFileSync(widgetPath, 'utf8');
1993
+ const widgetContext = {
1994
+ ...context,
1995
+ widget: widget
1996
+ };
1997
+
1998
+ return await this.liquid.parseAndRender(widgetContent, widgetContext);
1999
+ } catch (error) {
2000
+ return `<div class="error">Error rendering widget: ${error.message}</div>`;
2001
+ }
2002
+ }
2003
+
2004
+ /**
2005
+ * Generate development dashboard HTML
2006
+ */
2007
+ generateDevDashboard({ templates, sections, widgets, snippets }) {
2008
+ return `<!DOCTYPE html>
2009
+ <html lang="en">
2010
+ <head>
2011
+ <meta charset="UTF-8">
2012
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2013
+ <title>O2VEND Theme Development Dashboard</title>
2014
+ <link rel="icon" type="image/png" href="/o2vend-assets/favicon.png">
2015
+ <style>
2016
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2017
+ :root {
2018
+ --o2vend-primary: #F9A825;
2019
+ --o2vend-primary-dark: #E59100;
2020
+ --o2vend-primary-light: #FFCA28;
2021
+ --o2vend-dark: #1A1A1A;
2022
+ --o2vend-text: #333333;
2023
+ --o2vend-bg: #FFFDF7;
2024
+ --o2vend-surface: #FFFFFF;
2025
+ --o2vend-border: #FFF3D0;
2026
+ }
2027
+ body {
2028
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
2029
+ background: var(--o2vend-bg);
2030
+ color: var(--o2vend-text);
2031
+ line-height: 1.6;
2032
+ }
2033
+ .container {
2034
+ max-width: 1400px;
2035
+ margin: 0 auto;
2036
+ padding: 20px;
2037
+ }
2038
+ header {
2039
+ background: linear-gradient(135deg, var(--o2vend-primary) 0%, var(--o2vend-primary-dark) 100%);
2040
+ color: var(--o2vend-dark);
2041
+ padding: 40px 30px;
2042
+ margin: -20px -20px 30px -20px;
2043
+ border-radius: 0 0 16px 16px;
2044
+ box-shadow: 0 4px 20px rgba(249, 168, 37, 0.3);
2045
+ }
2046
+ .header-content {
2047
+ display: flex;
2048
+ align-items: center;
2049
+ gap: 20px;
2050
+ margin-bottom: 15px;
2051
+ }
2052
+ .logo-container {
2053
+ display: flex;
2054
+ align-items: center;
2055
+ gap: 15px;
2056
+ }
2057
+ .logo-img {
2058
+ height: 50px;
2059
+ width: auto;
2060
+ }
2061
+ header h1 {
2062
+ font-size: 2.2em;
2063
+ font-weight: 700;
2064
+ color: var(--o2vend-dark);
2065
+ display: flex;
2066
+ align-items: center;
2067
+ gap: 10px;
2068
+ }
2069
+ .brand-text {
2070
+ font-weight: 800;
2071
+ letter-spacing: -0.5px;
2072
+ }
2073
+ header p {
2074
+ color: var(--o2vend-dark);
2075
+ opacity: 0.8;
2076
+ font-size: 1.1em;
2077
+ margin-left: 75px;
2078
+ }
2079
+ .nav-links {
2080
+ margin-top: 20px;
2081
+ display: flex;
2082
+ gap: 12px;
2083
+ flex-wrap: wrap;
2084
+ }
2085
+ .nav-links a {
2086
+ color: var(--o2vend-dark);
2087
+ text-decoration: none;
2088
+ padding: 10px 18px;
2089
+ background: rgba(255,255,255,0.9);
2090
+ border-radius: 8px;
2091
+ transition: all 0.3s;
2092
+ font-weight: 500;
2093
+ font-size: 0.95em;
2094
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
2095
+ }
2096
+ .nav-links a:hover {
2097
+ background: white;
2098
+ transform: translateY(-2px);
2099
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
2100
+ }
2101
+ .section {
2102
+ background: var(--o2vend-surface);
2103
+ border-radius: 12px;
2104
+ padding: 28px;
2105
+ margin-bottom: 25px;
2106
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
2107
+ border: 1px solid var(--o2vend-border);
2108
+ }
2109
+ .section h2 {
2110
+ color: var(--o2vend-dark);
2111
+ margin-bottom: 15px;
2112
+ font-size: 1.6em;
2113
+ font-weight: 700;
2114
+ border-bottom: 3px solid var(--o2vend-primary);
2115
+ padding-bottom: 12px;
2116
+ display: flex;
2117
+ align-items: center;
2118
+ gap: 10px;
2119
+ }
2120
+ .section-count {
2121
+ color: #888;
2122
+ font-size: 0.85em;
2123
+ font-weight: 500;
2124
+ background: var(--o2vend-border);
2125
+ padding: 4px 10px;
2126
+ border-radius: 20px;
2127
+ }
2128
+ .file-grid {
2129
+ display: grid;
2130
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
2131
+ gap: 16px;
2132
+ margin-top: 20px;
2133
+ }
2134
+ .file-item {
2135
+ background: var(--o2vend-bg);
2136
+ border: 2px solid var(--o2vend-border);
2137
+ border-radius: 10px;
2138
+ padding: 18px;
2139
+ transition: all 0.3s;
2140
+ text-decoration: none;
2141
+ color: var(--o2vend-text);
2142
+ display: block;
2143
+ }
2144
+ .file-item:hover {
2145
+ border-color: var(--o2vend-primary);
2146
+ transform: translateY(-3px);
2147
+ box-shadow: 0 6px 16px rgba(249, 168, 37, 0.2);
2148
+ background: white;
2149
+ }
2150
+ .file-item h3 {
2151
+ font-size: 1.05em;
2152
+ margin-bottom: 6px;
2153
+ color: var(--o2vend-dark);
2154
+ font-weight: 600;
2155
+ }
2156
+ .file-item:hover h3 {
2157
+ color: var(--o2vend-primary-dark);
2158
+ }
2159
+ .file-item .path {
2160
+ font-size: 0.82em;
2161
+ color: #777;
2162
+ word-break: break-all;
2163
+ font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
2164
+ }
2165
+ .empty {
2166
+ color: #999;
2167
+ font-style: italic;
2168
+ padding: 30px;
2169
+ text-align: center;
2170
+ background: var(--o2vend-bg);
2171
+ border-radius: 8px;
2172
+ }
2173
+ .quick-links {
2174
+ display: flex;
2175
+ gap: 12px;
2176
+ margin-top: 20px;
2177
+ flex-wrap: wrap;
2178
+ }
2179
+ .quick-link {
2180
+ padding: 12px 24px;
2181
+ background: var(--o2vend-primary);
2182
+ color: var(--o2vend-dark);
2183
+ text-decoration: none;
2184
+ border-radius: 8px;
2185
+ transition: all 0.3s;
2186
+ font-weight: 600;
2187
+ box-shadow: 0 2px 8px rgba(249, 168, 37, 0.3);
2188
+ }
2189
+ .quick-link:hover {
2190
+ background: var(--o2vend-primary-dark);
2191
+ transform: translateY(-2px);
2192
+ box-shadow: 0 4px 12px rgba(249, 168, 37, 0.4);
2193
+ }
2194
+ .footer-credit {
2195
+ text-align: center;
2196
+ padding: 30px;
2197
+ color: #888;
2198
+ font-size: 0.9em;
2199
+ }
2200
+ .footer-credit a {
2201
+ color: var(--o2vend-primary-dark);
2202
+ text-decoration: none;
2203
+ font-weight: 600;
2204
+ }
2205
+ </style>
2206
+ </head>
2207
+ <body>
2208
+ <div class="container">
2209
+ <header>
2210
+ <div class="header-content">
2211
+ <div class="logo-container">
2212
+ <img src="/o2vend-assets/Logo_o2vend.png" alt="O2VEND" class="logo-img">
2213
+ <h1>Theme Dev</h1>
2214
+ </div>
2215
+ </div>
2216
+ <p>Development Dashboard - View and test all your theme components</p>
2217
+ <div class="nav-links">
2218
+ <a href="/">šŸ  Homepage</a>
2219
+ <a href="/products">šŸ“¦ Products</a>
2220
+ <a href="/products/product-1">šŸ“± Product Detail</a>
2221
+ <a href="/categories">šŸ“ Categories</a>
2222
+ <a href="/categories/electronics">⚔ Electronics</a>
2223
+ <a href="/brands">šŸ·ļø Brands</a>
2224
+ <a href="/cart">šŸ›’ Cart</a>
2225
+ <a href="/search?q=wireless">šŸ” Search</a>
2226
+ <a href="/page/about">šŸ“„ About</a>
2227
+ <a href="/page/contact">šŸ“ž Contact</a>
2228
+ </div>
2229
+ </header>
2230
+
2231
+ <div class="section">
2232
+ <h2>šŸ“„ Templates <span class="section-count">(${templates.length})</span></h2>
2233
+ ${templates.length > 0 ? `
2234
+ <div class="file-grid">
2235
+ ${templates.map(t => `
2236
+ <a href="/dev/template/${t.path}" class="file-item">
2237
+ <h3>${t.name}</h3>
2238
+ <div class="path">${t.path}</div>
2239
+ </a>
2240
+ `).join('')}
2241
+ </div>
2242
+ ` : '<div class="empty">No templates found</div>'}
2243
+ </div>
2244
+
2245
+ <div class="section">
2246
+ <h2>🧩 Sections <span class="section-count">(${sections.length})</span></h2>
2247
+ ${sections.length > 0 ? `
2248
+ <div class="file-grid">
2249
+ ${sections.map(s => `
2250
+ <a href="/dev/section/${s.name}" class="file-item">
2251
+ <h3>${s.name}</h3>
2252
+ <div class="path">sections/${s.name}.liquid</div>
2253
+ </a>
2254
+ `).join('')}
2255
+ </div>
2256
+ ` : '<div class="empty">No sections found</div>'}
2257
+ </div>
2258
+
2259
+ <div class="section">
2260
+ <h2>šŸŽÆ Widgets <span class="section-count">(${widgets.length})</span></h2>
2261
+ ${widgets.length > 0 ? `
2262
+ <div class="file-grid">
2263
+ ${widgets.map(w => `
2264
+ <a href="/dev/widget/${w.name}" class="file-item">
2265
+ <h3>${w.name}</h3>
2266
+ <div class="path">widgets/${w.name}.liquid</div>
2267
+ </a>
2268
+ `).join('')}
2269
+ </div>
2270
+ ` : '<div class="empty">No widgets found</div>'}
2271
+ </div>
2272
+
2273
+ <div class="section">
2274
+ <h2>āœ‚ļø Snippets <span class="section-count">(${snippets.length})</span></h2>
2275
+ ${snippets.length > 0 ? `
2276
+ <div class="file-grid">
2277
+ ${snippets.map(s => {
2278
+ const snippetName = typeof s === 'string' ? s : (s.name || s.path || '');
2279
+ return `
2280
+ <a href="/dev/snippet/${encodeURIComponent(snippetName)}" class="file-item">
2281
+ <h3>${snippetName}</h3>
2282
+ <div class="path">snippets/${snippetName}.liquid</div>
2283
+ </a>
2284
+ `;
2285
+ }).join('')}
2286
+ </div>
2287
+ ` : '<div class="empty">No snippets found</div>'}
2288
+ </div>
2289
+
2290
+ <div class="footer-credit">
2291
+ <p>Powered by <a href="https://www.o2vend.com" target="_blank">O2VEND</a> Theme Development Tools</p>
2292
+ <p style="margin-top: 8px; font-size: 0.85em; opacity: 0.7;">Version 1.0.0 | Running on port ${this.port || 3000}</p>
2293
+ </div>
2294
+ </div>
2295
+ </body>
2296
+ </html>`;
2297
+ }
2298
+
2299
+ /**
2300
+ * Generate preview page HTML
2301
+ */
2302
+ generatePreviewPage({ title, content, type, name, backUrl }) {
2303
+ return `<!DOCTYPE html>
2304
+ <html lang="en">
2305
+ <head>
2306
+ <meta charset="UTF-8">
2307
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
2308
+ <title>${title} - O2VEND Dev</title>
2309
+ <link rel="icon" type="image/png" href="/o2vend-assets/favicon.png">
2310
+ <style>
2311
+ * { margin: 0; padding: 0; box-sizing: border-box; }
2312
+ :root {
2313
+ --o2vend-primary: #F9A825;
2314
+ --o2vend-primary-dark: #E59100;
2315
+ --o2vend-dark: #1A1A1A;
2316
+ --o2vend-bg: #FFFDF7;
2317
+ }
2318
+ body {
2319
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
2320
+ background: var(--o2vend-bg);
2321
+ }
2322
+ .header {
2323
+ background: linear-gradient(135deg, var(--o2vend-primary) 0%, var(--o2vend-primary-dark) 100%);
2324
+ color: var(--o2vend-dark);
2325
+ padding: 20px;
2326
+ box-shadow: 0 4px 12px rgba(249, 168, 37, 0.3);
2327
+ }
2328
+ .header-content {
2329
+ max-width: 1400px;
2330
+ margin: 0 auto;
2331
+ display: flex;
2332
+ align-items: center;
2333
+ gap: 20px;
2334
+ }
2335
+ .back-btn {
2336
+ background: rgba(255,255,255,0.9);
2337
+ color: var(--o2vend-dark);
2338
+ text-decoration: none;
2339
+ padding: 10px 20px;
2340
+ border-radius: 8px;
2341
+ transition: all 0.3s;
2342
+ font-weight: 500;
2343
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
2344
+ }
2345
+ .back-btn:hover {
2346
+ background: white;
2347
+ transform: translateY(-2px);
2348
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
2349
+ }
2350
+ .header h1 {
2351
+ font-size: 1.6em;
2352
+ font-weight: 700;
2353
+ }
2354
+ .preview-container {
2355
+ max-width: 1400px;
2356
+ margin: 20px auto;
2357
+ background: white;
2358
+ border-radius: 12px;
2359
+ padding: 30px;
2360
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
2361
+ border: 1px solid #FFF3D0;
2362
+ }
2363
+ .info {
2364
+ background: #FFF8E1;
2365
+ border-left: 4px solid var(--o2vend-primary);
2366
+ padding: 15px 20px;
2367
+ margin-bottom: 25px;
2368
+ border-radius: 6px;
2369
+ font-size: 0.95em;
2370
+ }
2371
+ .info strong {
2372
+ color: var(--o2vend-primary-dark);
2373
+ font-weight: 600;
2374
+ }
2375
+ .error {
2376
+ background: #ffebee;
2377
+ border-left: 4px solid #f44336;
2378
+ padding: 15px;
2379
+ margin: 20px 0;
2380
+ border-radius: 6px;
2381
+ color: #c62828;
2382
+ }
2383
+ </style>
2384
+ </head>
2385
+ <body>
2386
+ <div class="header">
2387
+ <div class="header-content">
2388
+ <a href="${backUrl}" class="back-btn">← Back to Dashboard</a>
2389
+ <h1>${title}</h1>
2390
+ </div>
2391
+ </div>
2392
+ <div class="preview-container">
2393
+ <div class="info">
2394
+ <strong>Type:</strong> ${type} &nbsp;|&nbsp; <strong>Name:</strong> ${name}
2395
+ </div>
2396
+ ${content}
2397
+ </div>
2398
+ </body>
2399
+ </html>`;
2400
+ }
2401
+
2402
+ /**
2403
+ * Start development server
2404
+ */
2405
+ async start() {
2406
+ try {
2407
+ // Start mock API first if in mock mode (before setting up services)
2408
+ if (this.mode === 'mock') {
2409
+ const MockApiServer = require('./mock-api-server');
2410
+ this.mockApi = new MockApiServer({ port: this.mockApiPort });
2411
+ await this.mockApi.start();
2412
+ // Update mockApiPort in case it changed
2413
+ this.mockApiPort = this.mockApi.port;
2414
+ }
2415
+
2416
+ // Setup services (after mock API is running)
2417
+ await this.setupServices();
2418
+
2419
+ // In mock mode, ensure API client points directly to mock API
2420
+ if (this.mode === 'mock' && this.mockApi) {
2421
+ // Update API client to point directly to mock API with shorter timeout
2422
+ const originalTimeout = process.env.O2VEND_API_TIMEOUT;
2423
+ process.env.O2VEND_API_TIMEOUT = '2000'; // 2 seconds for local mock API
2424
+ this.apiClient = new O2VendApiClient('mock-tenant', 'mock-key', `http://localhost:${this.mockApiPort}`);
2425
+ if (originalTimeout) {
2426
+ process.env.O2VEND_API_TIMEOUT = originalTimeout;
2427
+ }
2428
+
2429
+ // Update widget service with new API client
2430
+ this.widgetService = new WidgetService(this.apiClient, {
2431
+ theme: path.basename(this.themePath),
2432
+ themePath: this.themePath
2433
+ });
2434
+
2435
+ // Re-register widget filter now that widget service is available
2436
+ // IMPORTANT: This must be called AFTER widgetService is created
2437
+ console.log('[DEV SERVER] Re-registering widget filter with widgetService');
2438
+ this.setupWidgetFilter();
2439
+
2440
+ // Verify filter is registered
2441
+ // LiquidJS stores filters in engine.filters object
2442
+ const registeredFilters = Object.keys(this.liquid.filters || {});
2443
+ const hasRenderWidget = registeredFilters.includes('render_widget');
2444
+ console.log(`[DEV SERVER] Filter registration check: render_widget filter ${hasRenderWidget ? 'āœ… registered' : 'āŒ NOT registered'}`);
2445
+ console.log(`[DEV SERVER] Total registered filters: ${registeredFilters.length}`);
2446
+
2447
+ // Test if filter function exists and test it
2448
+ if (hasRenderWidget) {
2449
+ const filterFunc = this.liquid.filters['render_widget'];
2450
+ const isAsync = filterFunc && filterFunc.constructor && filterFunc.constructor.name === 'AsyncFunction';
2451
+ const isGenerator = filterFunc && filterFunc.constructor && filterFunc.constructor.name === 'GeneratorFunction';
2452
+ console.log(`[DEV SERVER] Filter function type: ${isAsync ? 'AsyncFunction āœ…' : isGenerator ? 'GeneratorFunction' : typeof filterFunc}`);
2453
+
2454
+ // Test the filter with a mock widget to see if it's callable
2455
+ try {
2456
+ const testWidget = { type: 'TestWidget', id: 'test-1' };
2457
+ console.log(`[DEV SERVER] Testing filter with mock widget...`);
2458
+ // Don't actually call it (would fail), just verify it's a function
2459
+ if (typeof filterFunc === 'function') {
2460
+ console.log(`[DEV SERVER] āœ… Filter is a callable function`);
2461
+ } else {
2462
+ console.error(`[DEV SERVER] āŒ Filter is not a function: ${typeof filterFunc}`);
2463
+ }
2464
+ } catch (error) {
2465
+ console.error(`[DEV SERVER] āŒ Error testing filter: ${error.message}`);
2466
+ }
2467
+ }
2468
+
2469
+ if (registeredFilters.length > 0 && registeredFilters.length < 20) {
2470
+ console.log(`[DEV SERVER] Sample registered filters: ${registeredFilters.slice(0, 10).join(', ')}${registeredFilters.length > 10 ? '...' : ''}`);
2471
+ }
2472
+
2473
+ // Setup API proxy to mock API (for direct browser requests)
2474
+ const { createProxyMiddleware } = require('http-proxy-middleware');
2475
+ this.app.use('/shopfront/api', createProxyMiddleware({
2476
+ target: `http://localhost:${this.mockApiPort}`,
2477
+ changeOrigin: true,
2478
+ logLevel: 'silent',
2479
+ timeout: 2000
2480
+ }));
2481
+
2482
+ // Proxy webstoreapi endpoints to mock API
2483
+ this.app.use('/webstoreapi', createProxyMiddleware({
2484
+ target: `http://localhost:${this.mockApiPort}`,
2485
+ changeOrigin: true,
2486
+ logLevel: 'silent',
2487
+ timeout: 2000
2488
+ }));
2489
+ }
2490
+
2491
+ // Inject hot reload script into HTML responses
2492
+ const { injectHotReloadScript } = require('./hot-reload');
2493
+ const self = this;
2494
+ this.app.use((req, res, next) => {
2495
+ const originalSend = res.send;
2496
+ res.send = function(data) {
2497
+ if (typeof data === 'string' && (data.includes('<!DOCTYPE') || data.includes('<html'))) {
2498
+ data = injectHotReloadScript(data, self.host, self.port);
2499
+ }
2500
+ return originalSend.call(res, data);
2501
+ };
2502
+ next();
2503
+ });
2504
+
2505
+ // Create HTTP server and attach Socket.IO
2506
+ const http = require('http');
2507
+ this.server = http.createServer(this.app);
2508
+
2509
+ // Setup hot reload (WebSocket) - attach to HTTP server
2510
+ this.io = new (require('socket.io').Server)(this.server, {
2511
+ path: '/socket.io',
2512
+ serveClient: true,
2513
+ cors: {
2514
+ origin: '*',
2515
+ methods: ['GET', 'POST']
2516
+ }
2517
+ });
2518
+
2519
+ this.io.on('connection', (socket) => {
2520
+ console.log(chalk.magenta('[Hot Reload] Client connected'));
2521
+ socket.on('disconnect', () => {
2522
+ console.log(chalk.magenta('[Hot Reload] Client disconnected'));
2523
+ });
2524
+ });
2525
+
2526
+ // Setup file watching
2527
+ const { setupFileWatcher } = require('./file-watcher');
2528
+ this.fileWatcher = setupFileWatcher(this.themePath, (changedFiles) => {
2529
+ // Notify clients via WebSocket
2530
+ if (this.io) {
2531
+ this.io.emit('file-changed', changedFiles);
2532
+ }
2533
+
2534
+ // Log changes
2535
+ if (changedFiles.some(f => f.endsWith('.liquid'))) {
2536
+ console.log(chalk.cyan('šŸ”„ Liquid templates changed - reloading...'));
2537
+ } else if (changedFiles.some(f => f.endsWith('.css'))) {
2538
+ console.log(chalk.cyan('šŸŽØ CSS changed - injecting...'));
2539
+ } else if (changedFiles.some(f => f.endsWith('.js'))) {
2540
+ console.log(chalk.cyan('šŸ“œ JavaScript changed - reloading...'));
2541
+ }
2542
+ });
2543
+
2544
+ // Start HTTP server
2545
+ return new Promise((resolve, reject) => {
2546
+ this.server.listen(this.port, this.host, (error) => {
2547
+ if (error) {
2548
+ reject(error);
2549
+ return;
2550
+ }
2551
+
2552
+ const url = `http://${this.host}:${this.port}`;
2553
+ console.log(chalk.green(`\nāœ… Server started successfully!`));
2554
+ console.log(chalk.cyan(`šŸ“” Server: ${url}`));
2555
+ console.log(chalk.cyan(`🌐 Mode: ${this.mode === 'mock' ? 'Mock API' : 'Real API'}`));
2556
+
2557
+ if (this.mode === 'mock') {
2558
+ console.log(chalk.cyan(`šŸŽÆ Mock API: http://${this.host}:${this.mockApiPort}`));
2559
+ }
2560
+
2561
+ console.log(chalk.magenta(`\nšŸŽØ Development Dashboard: ${url}/dev`));
2562
+ console.log(chalk.gray(` View and test all templates, sections, widgets, and snippets`));
2563
+
2564
+ console.log(chalk.yellow(`\nšŸ“ Watching for changes...`));
2565
+ console.log(chalk.yellow(`šŸ’” Press 'q' to quit\n`));
2566
+
2567
+ // Open browser if requested
2568
+ if (this.openBrowser) {
2569
+ const open = require('open');
2570
+ open(url).catch(() => {
2571
+ console.log(chalk.yellow(`šŸ’” Open your browser: ${url}`));
2572
+ });
2573
+ }
2574
+
2575
+ resolve(this.server);
2576
+ });
2577
+ });
2578
+ } catch (error) {
2579
+ console.error(chalk.red('āŒ Failed to start server:'), error);
2580
+ throw error;
2581
+ }
2582
+ }
2583
+
2584
+ /**
2585
+ * Stop development server
2586
+ */
2587
+ async stop() {
2588
+ return new Promise((resolve) => {
2589
+ // Stop file watcher
2590
+ if (this.fileWatcher && typeof this.fileWatcher.close === 'function') {
2591
+ this.fileWatcher.close();
2592
+ }
2593
+
2594
+ // Close Socket.IO
2595
+ if (this.io && typeof this.io.close === 'function') {
2596
+ this.io.close();
2597
+ }
2598
+
2599
+ // Stop mock API
2600
+ if (this.mockApi && typeof this.mockApi.stop === 'function') {
2601
+ this.mockApi.stop().catch(() => {});
2602
+ }
2603
+
2604
+ // Close HTTP server
2605
+ if (this.server) {
2606
+ this.server.close(() => {
2607
+ console.log(chalk.yellow('\nšŸ‘‹ Server stopped'));
2608
+ resolve();
2609
+ });
2610
+ } else {
2611
+ resolve();
2612
+ }
2613
+ });
2614
+ }
2615
+ }
2616
+
2617
+ module.exports = DevServer;
2618
+