@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.
- package/README.md +425 -0
- package/assets/Logo_o2vend.png +0 -0
- package/assets/favicon.png +0 -0
- package/assets/logo-white.png +0 -0
- package/bin/o2vend +42 -0
- package/config/widget-map.json +50 -0
- package/lib/commands/check.js +201 -0
- package/lib/commands/generate.js +33 -0
- package/lib/commands/init.js +214 -0
- package/lib/commands/optimize.js +216 -0
- package/lib/commands/package.js +208 -0
- package/lib/commands/serve.js +105 -0
- package/lib/commands/validate.js +191 -0
- package/lib/lib/api-client.js +357 -0
- package/lib/lib/dev-server.js +2618 -0
- package/lib/lib/file-watcher.js +80 -0
- package/lib/lib/hot-reload.js +106 -0
- package/lib/lib/liquid-engine.js +822 -0
- package/lib/lib/liquid-filters.js +671 -0
- package/lib/lib/mock-api-server.js +989 -0
- package/lib/lib/mock-data.js +1468 -0
- package/lib/lib/widget-service.js +321 -0
- package/package.json +70 -0
- package/test-theme/README.md +27 -0
- package/test-theme/assets/async-sections.js +446 -0
- package/test-theme/assets/cart-drawer.js +463 -0
- package/test-theme/assets/cart-manager.js +223 -0
- package/test-theme/assets/checkout-price-handler.js +368 -0
- package/test-theme/assets/components.css +4629 -0
- package/test-theme/assets/delivery-zone.css +299 -0
- package/test-theme/assets/delivery-zone.js +396 -0
- package/test-theme/assets/logo.png +0 -0
- package/test-theme/assets/sections.css +48 -0
- package/test-theme/assets/theme.css +3500 -0
- package/test-theme/assets/theme.js +3745 -0
- package/test-theme/config/settings_data.json +292 -0
- package/test-theme/config/settings_schema.json +1050 -0
- package/test-theme/layout/theme.liquid +195 -0
- package/test-theme/locales/en.default.json +260 -0
- package/test-theme/sections/content-fallback.liquid +53 -0
- package/test-theme/sections/content.liquid +57 -0
- package/test-theme/sections/footer-fallback.liquid +328 -0
- package/test-theme/sections/footer.liquid +278 -0
- package/test-theme/sections/header-fallback.liquid +1805 -0
- package/test-theme/sections/header.liquid +1145 -0
- package/test-theme/sections/hero-fallback.liquid +212 -0
- package/test-theme/sections/hero.liquid +136 -0
- package/test-theme/snippets/account-sidebar.liquid +200 -0
- package/test-theme/snippets/add-to-cart-modal.liquid +484 -0
- package/test-theme/snippets/breadcrumbs.liquid +134 -0
- package/test-theme/snippets/cart-drawer.liquid +467 -0
- package/test-theme/snippets/delivery-zone-city-selector.liquid +79 -0
- package/test-theme/snippets/delivery-zone-modal.liquid +337 -0
- package/test-theme/snippets/delivery-zone-search.liquid +78 -0
- package/test-theme/snippets/icon.liquid +105 -0
- package/test-theme/snippets/login-modal.liquid +346 -0
- package/test-theme/snippets/mega-menu.liquid +812 -0
- package/test-theme/snippets/news-thumbnail.liquid +187 -0
- package/test-theme/snippets/pagination.liquid +120 -0
- package/test-theme/snippets/price.liquid +92 -0
- package/test-theme/snippets/product-card-related.liquid +78 -0
- package/test-theme/snippets/product-card-simple.liquid +41 -0
- package/test-theme/snippets/product-card.liquid +697 -0
- package/test-theme/snippets/rating.liquid +85 -0
- package/test-theme/snippets/skeleton-collection-grid.liquid +114 -0
- package/test-theme/snippets/skeleton-product-card.liquid +124 -0
- package/test-theme/snippets/skeleton-product-grid.liquid +34 -0
- package/test-theme/snippets/social-sharing.liquid +185 -0
- package/test-theme/templates/account/dashboard.liquid +401 -0
- package/test-theme/templates/account/loyalty-redemption.liquid +405 -0
- package/test-theme/templates/account/loyalty.liquid +588 -0
- package/test-theme/templates/account/order-detail.liquid +230 -0
- package/test-theme/templates/account/orders.liquid +349 -0
- package/test-theme/templates/account/profile.liquid +758 -0
- package/test-theme/templates/account/register.liquid +232 -0
- package/test-theme/templates/account/return-orders.liquid +348 -0
- package/test-theme/templates/account/store-credit.liquid +464 -0
- package/test-theme/templates/account/subscriptions.liquid +601 -0
- package/test-theme/templates/account/wishlist.liquid +419 -0
- package/test-theme/templates/address-book.liquid +1092 -0
- package/test-theme/templates/categories.liquid +452 -0
- package/test-theme/templates/checkout.liquid +4511 -0
- package/test-theme/templates/error.liquid +384 -0
- package/test-theme/templates/index.liquid +11 -0
- package/test-theme/templates/login.liquid +185 -0
- package/test-theme/templates/order-confirmation.liquid +720 -0
- package/test-theme/templates/page.liquid +297 -0
- package/test-theme/templates/product-detail.liquid +4363 -0
- package/test-theme/templates/products.liquid +518 -0
- package/test-theme/templates/search.liquid +922 -0
- package/test-theme/theme.json.example +19 -0
- package/test-theme/widgets/brand-carousel.liquid +676 -0
- package/test-theme/widgets/brand.liquid +245 -0
- package/test-theme/widgets/carousel.liquid +843 -0
- package/test-theme/widgets/category-list-carousel.liquid +656 -0
- package/test-theme/widgets/category-list.liquid +340 -0
- package/test-theme/widgets/category.liquid +475 -0
- package/test-theme/widgets/discount-time.liquid +176 -0
- package/test-theme/widgets/footer-menu.liquid +695 -0
- package/test-theme/widgets/footer.liquid +179 -0
- package/test-theme/widgets/gallery.liquid +271 -0
- package/test-theme/widgets/header-menu.liquid +932 -0
- package/test-theme/widgets/header.liquid +159 -0
- package/test-theme/widgets/html.liquid +214 -0
- package/test-theme/widgets/news.liquid +217 -0
- package/test-theme/widgets/product-canvas.liquid +235 -0
- package/test-theme/widgets/product-carousel.liquid +502 -0
- package/test-theme/widgets/product.liquid +45 -0
- package/test-theme/widgets/recently-viewed.liquid +26 -0
- package/test-theme/widgets/shared/product-grid.liquid +339 -0
- package/test-theme/widgets/simple-product.liquid +42 -0
- package/test-theme/widgets/single-product.liquid +610 -0
- package/test-theme/widgets/spacebar-carousel.liquid +663 -0
- package/test-theme/widgets/spacebar.liquid +279 -0
- package/test-theme/widgets/splash.liquid +378 -0
- 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>© ${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} | <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
|
+
|