@o2vend/theme-cli 1.0.36 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +4 -0
  2. package/lib/lib/dev-server.js +344 -48
  3. package/lib/lib/liquid-engine.js +3 -1
  4. package/lib/lib/mock-data.js +473 -119
  5. package/lib/lib/widget-service.js +12 -4
  6. package/package.json +2 -2
  7. package/test-theme/assets/async-sections.js +32 -24
  8. package/test-theme/assets/cart-drawer.js +20 -22
  9. package/test-theme/assets/cart-manager.js +1 -15
  10. package/test-theme/assets/checkout-price-handler.js +12 -11
  11. package/test-theme/assets/checkout.css +1415 -0
  12. package/test-theme/assets/checkout.js +3174 -0
  13. package/test-theme/assets/components.css +178 -29
  14. package/test-theme/assets/delivery-zone.js +1 -1
  15. package/test-theme/assets/product-detail.css +1050 -0
  16. package/test-theme/assets/product-detail.js +2940 -0
  17. package/test-theme/assets/theme.css +95 -120
  18. package/test-theme/assets/theme.js +781 -186
  19. package/test-theme/layout/theme.liquid +91 -17
  20. package/test-theme/sections/content.liquid +64 -57
  21. package/test-theme/sections/footer-fallback.liquid +57 -7
  22. package/test-theme/sections/footer.liquid +63 -12
  23. package/test-theme/sections/header-fallback.liquid +41 -41
  24. package/test-theme/sections/header.liquid +41 -51
  25. package/test-theme/sections/hero-fallback.liquid +1 -1
  26. package/test-theme/sections/hero.liquid +159 -136
  27. package/test-theme/snippets/account-sidebar.liquid +121 -29
  28. package/test-theme/snippets/add-to-cart-modal.liquid +258 -206
  29. package/test-theme/snippets/breadcrumbs.liquid +98 -11
  30. package/test-theme/snippets/cart-drawer.liquid +93 -0
  31. package/test-theme/snippets/delivery-zone-city-selector.liquid +101 -15
  32. package/test-theme/snippets/delivery-zone-modal.liquid +529 -84
  33. package/test-theme/snippets/delivery-zone-search.liquid +104 -18
  34. package/test-theme/snippets/login-modal.liquid +269 -82
  35. package/test-theme/snippets/mega-menu.liquid +130 -43
  36. package/test-theme/snippets/news-thumbnail.liquid +120 -28
  37. package/test-theme/snippets/pagination.liquid +1 -1
  38. package/test-theme/snippets/price.liquid +100 -9
  39. package/test-theme/snippets/product-card-related.liquid +22 -4
  40. package/test-theme/snippets/product-card-simple.liquid +521 -25
  41. package/test-theme/snippets/product-card.liquid +145 -232
  42. package/test-theme/snippets/rating.liquid +100 -9
  43. package/test-theme/snippets/skeleton-collection-grid.liquid +94 -8
  44. package/test-theme/snippets/skeleton-product-card.liquid +102 -16
  45. package/test-theme/snippets/skeleton-product-grid.liquid +87 -1
  46. package/test-theme/snippets/social-sharing.liquid +133 -32
  47. package/test-theme/templates/account/dashboard.liquid +30 -0
  48. package/test-theme/templates/account/loyalty-redemption.liquid +29 -28
  49. package/test-theme/templates/account/loyalty.liquid +45 -43
  50. package/test-theme/templates/account/order-detail.liquid +15 -8
  51. package/test-theme/templates/account/orders.liquid +189 -35
  52. package/test-theme/templates/account/profile.liquid +509 -114
  53. package/test-theme/templates/account/register.liquid +18 -8
  54. package/test-theme/templates/account/return-orders.liquid +31 -30
  55. package/test-theme/templates/account/store-credit.liquid +27 -26
  56. package/test-theme/templates/account/subscriptions.liquid +22 -5
  57. package/test-theme/templates/account/wishlist.liquid +88 -19
  58. package/test-theme/templates/address-book.liquid +166 -69
  59. package/test-theme/templates/categories.liquid +90 -30
  60. package/test-theme/templates/checkout.liquid +137 -3834
  61. package/test-theme/templates/error.liquid +23 -21
  62. package/test-theme/templates/index.liquid +29 -0
  63. package/test-theme/templates/login.liquid +33 -6
  64. package/test-theme/templates/order-confirmation.liquid +67 -9
  65. package/test-theme/templates/page.liquid +418 -206
  66. package/test-theme/templates/product-detail.liquid +124 -3878
  67. package/test-theme/templates/products.liquid +155 -30
  68. package/test-theme/templates/search.liquid +739 -225
  69. package/test-theme/widgets/brand-carousel.liquid +102 -82
  70. package/test-theme/widgets/brand.liquid +78 -50
  71. package/test-theme/widgets/carousel.liquid +253 -121
  72. package/test-theme/widgets/category-list-carousel.liquid +32 -8
  73. package/test-theme/widgets/category-list.liquid +21 -6
  74. package/test-theme/widgets/category.liquid +104 -37
  75. package/test-theme/widgets/discount-time.liquid +326 -119
  76. package/test-theme/widgets/footer-menu.liquid +115 -23
  77. package/test-theme/widgets/footer.liquid +118 -5
  78. package/test-theme/widgets/gallery.liquid +29 -5
  79. package/test-theme/widgets/header-menu.liquid +25 -13
  80. package/test-theme/widgets/header.liquid +64 -26
  81. package/test-theme/widgets/html.liquid +29 -6
  82. package/test-theme/widgets/news.liquid +6 -0
  83. package/test-theme/widgets/product-canvas.liquid +20 -12
  84. package/test-theme/widgets/product-carousel.liquid +118 -56
  85. package/test-theme/widgets/shared/product-grid.liquid +12 -0
  86. package/test-theme/widgets/single-product.liquid +688 -250
  87. package/test-theme/widgets/spacebar-carousel.liquid +39 -10
  88. package/test-theme/widgets/spacebar.liquid +77 -6
  89. package/test-theme/widgets/splash.liquid +40 -30
  90. package/test-theme/widgets/testimonial-carousel.liquid +111 -67
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  The O2VEND Theme CLI is a complete development environment for creating, testing, and packaging O2VEND themes. It works independently and doesn't require access to the main O2VEND webstore solution.
6
6
 
7
+ ## Prerequisites
8
+
9
+ - **Node.js 24.0 or later** is required. Download from [nodejs.org](https://nodejs.org/).
10
+
7
11
  ## Installation
8
12
 
9
13
  Install globally via npm:
@@ -137,7 +137,7 @@ class DevServer {
137
137
  // Only process if it contains actual Liquid variables, not just comments
138
138
  if (cssContent.includes('{{') && cssContent.includes('settings.')) {
139
139
  try {
140
- const settings = this.loadThemeSettings();
140
+ const settings = { ...this.loadThemeSettings(), ...(this._realStoreSettings || {}) };
141
141
  const processedCss = await this.liquid.parseAndRender(cssContent, { settings });
142
142
  res.setHeader('Content-Type', 'text/css; charset=utf-8');
143
143
  res.setHeader('Cache-Control', 'no-cache');
@@ -356,7 +356,6 @@ class DevServer {
356
356
  if (fs.existsSync(altPath)) {
357
357
  console.log(`[WIDGET] ✅ Using shared widget: ${templateSlug}.liquid`);
358
358
  const widgetContent = fs.readFileSync(altPath, 'utf8');
359
- // Get full context - use current rendering context if available, otherwise use passed context
360
359
  const fullContext = liquidEngine.getCurrentContext ? liquidEngine.getCurrentContext() : null;
361
360
  const widgetContext = fullContext
362
361
  ? { ...fullContext, widget: widget }
@@ -380,7 +379,6 @@ class DevServer {
380
379
 
381
380
  console.log(`[WIDGET] ✅ Found widget template: ${widgetPath}`);
382
381
  const widgetContent = fs.readFileSync(widgetPath, 'utf8');
383
- // Get full context - use current rendering context if available
384
382
  const fullContext = liquidEngine.getCurrentContext ? liquidEngine.getCurrentContext() : null;
385
383
  const widgetContext = fullContext
386
384
  ? { ...fullContext, widget: widget }
@@ -1056,6 +1054,19 @@ class DevServer {
1056
1054
  }
1057
1055
  });
1058
1056
 
1057
+ // Checkout page (use pageType 'checkout' so only header/footer widgets load, not home widgets)
1058
+ this.app.get('/checkout', async (req, res, next) => {
1059
+ try {
1060
+ const context = await this.buildContext(req, 'checkout');
1061
+ const html = await renderWithLayout(this.liquid, 'templates/checkout', context, this.themePath);
1062
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
1063
+ res.setHeader('Cache-Control', 'no-cache');
1064
+ res.send(html);
1065
+ } catch (error) {
1066
+ next(error);
1067
+ }
1068
+ });
1069
+
1059
1070
  // Search page
1060
1071
  this.app.get('/search', async (req, res, next) => {
1061
1072
  try {
@@ -1244,7 +1255,7 @@ class DevServer {
1244
1255
  content: sectionContent,
1245
1256
  type: 'section',
1246
1257
  name: sectionName,
1247
- backUrl: '/dev'
1258
+ backUrl: '/dev#sections'
1248
1259
  });
1249
1260
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1250
1261
  res.setHeader('Cache-Control', 'no-cache');
@@ -1276,13 +1287,14 @@ class DevServer {
1276
1287
  }
1277
1288
  }
1278
1289
 
1279
- // Get mock widget data for this type
1290
+ // Get mock widget data for this type (widgetType is usually template slug, e.g. spacebar-carousel)
1280
1291
  const mockWidgets = this.mockApi?.mockData?.widgets || [];
1281
- const mockWidget = mockWidgets.find(w =>
1282
- w.type === widgetType ||
1283
- w.type.toLowerCase() === widgetType.toLowerCase() ||
1284
- w.id?.includes(widgetType)
1285
- ) || {
1292
+ const mockWidget = mockWidgets.find(w => {
1293
+ if (w.type === widgetType || w.id?.includes(widgetType)) return true;
1294
+ if (w.type && typeof w.type === 'string' && w.type.toLowerCase().replace(/-/g, '') === widgetType.toLowerCase().replace(/-/g, '')) return true;
1295
+ if (this.widgetService && w.type && this.widgetService.getTemplateSlug(w.type) === widgetType) return true;
1296
+ return false;
1297
+ }) || {
1286
1298
  id: `widget-${widgetType}-1`,
1287
1299
  type: widgetType,
1288
1300
  section: 'content',
@@ -1299,6 +1311,8 @@ class DevServer {
1299
1311
  if (this.widgetService) {
1300
1312
  widgetToRender = this.widgetService.normalizeWidget(mockWidget);
1301
1313
  }
1314
+ // Enrich single widget with products/news/etc so preview has data
1315
+ this.enrichSingleWidget(widgetToRender, context);
1302
1316
 
1303
1317
  const widgetContent = await this.renderWidget(widgetToRender, context);
1304
1318
 
@@ -1307,7 +1321,7 @@ class DevServer {
1307
1321
  content: widgetContent,
1308
1322
  type: 'widget',
1309
1323
  name: widgetType,
1310
- backUrl: '/dev'
1324
+ backUrl: '/dev#widgets'
1311
1325
  });
1312
1326
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1313
1327
  res.setHeader('Cache-Control', 'no-cache');
@@ -1336,18 +1350,10 @@ class DevServer {
1336
1350
  }
1337
1351
 
1338
1352
  const context = await this.buildContext(req, 'home');
1339
-
1340
- // Try to render snippet with common variables
1341
- const snippetContext = {
1353
+ const snippetContext = this.getSnippetMockContext(snippetName, {
1342
1354
  ...context,
1343
- snippet: {
1344
- name: snippetName
1345
- },
1346
- // Add common snippet variables
1347
- product: context.products?.[0] || context.product,
1348
- collection: context.collections?.[0] || context.collection,
1349
- cart: context.cart
1350
- };
1355
+ snippet: { name: snippetName }
1356
+ });
1351
1357
 
1352
1358
  const snippetContent = await this.liquid.renderFile(snippetPath, snippetContext);
1353
1359
 
@@ -1356,7 +1362,8 @@ class DevServer {
1356
1362
  content: snippetContent,
1357
1363
  type: 'snippet',
1358
1364
  name: snippetName,
1359
- backUrl: '/dev'
1365
+ backUrl: '/dev#snippets',
1366
+ includeThemeCss: true
1360
1367
  });
1361
1368
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
1362
1369
  res.setHeader('Cache-Control', 'no-cache');
@@ -1392,7 +1399,7 @@ class DevServer {
1392
1399
  else if (templateName.includes('search')) pageType = 'search';
1393
1400
  else if (templateName.includes('page')) pageType = 'page';
1394
1401
  else if (templateName.includes('account') || templateName.includes('login')) pageType = 'page';
1395
- else if (templateName.includes('checkout')) pageType = 'cart';
1402
+ else if (templateName.includes('checkout')) pageType = 'checkout';
1396
1403
 
1397
1404
  const context = await this.buildContext(req, pageType);
1398
1405
  const html = await renderWithLayout(this.liquid, `templates/${templateName}`, context, this.themePath);
@@ -1411,7 +1418,7 @@ class DevServer {
1411
1418
  const handle = req.params.handle;
1412
1419
 
1413
1420
  // Skip if it's a known route path or static file extension
1414
- const knownPaths = ['assets', 'images', 'api', 'dev', 'favicon.ico', 'products', 'collections', 'categories', 'brands', 'cart', 'search', 'page', 'pages'];
1421
+ const knownPaths = ['assets', 'images', 'api', 'dev', 'favicon.ico', 'products', 'collections', 'categories', 'brands', 'cart', 'checkout', 'search', 'page', 'pages'];
1415
1422
  const staticExtensions = ['.css', '.js', '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.woff', '.woff2', '.ttf', '.eot', '.ico'];
1416
1423
  const hasStaticExtension = staticExtensions.some(ext => handle.toLowerCase().endsWith(ext));
1417
1424
 
@@ -1840,7 +1847,7 @@ class DevServer {
1840
1847
  };
1841
1848
 
1842
1849
  // Load widgets filtered by page type (matches production behavior)
1843
- const pageIdMap = { home: 'home', product: 'product', collection: 'category', category: 'category', products: 'products', categories: 'categories', brand: 'brand', checkout: 'checkout', page: 'home' };
1850
+ const pageIdMap = { home: 'home', product: 'product', collection: 'category', category: 'category', products: 'products', categories: 'categories', collections: 'categories', brand: 'brand', cart: 'cart', checkout: 'checkout', page: 'home' };
1844
1851
  const widgetPageId = pageIdMap[pageType] || 'home';
1845
1852
  if (this.widgetService) {
1846
1853
  try {
@@ -2079,6 +2086,9 @@ class DevServer {
2079
2086
  const favicon = (storeInfo && storeInfo.favouriteIconUrl) ? storeInfo.favouriteIconUrl : '/favicon.ico';
2080
2087
  const storeName = (storeInfo && storeInfo.name) ? storeInfo.name : 'Store';
2081
2088
  const storeSettings = (storeInfo && storeInfo.settings) || {};
2089
+ if (Object.keys(storeSettings).length > 0) {
2090
+ this._realStoreSettings = storeSettings;
2091
+ }
2082
2092
  try {
2083
2093
  context.shop = {
2084
2094
  name: storeName,
@@ -2118,7 +2128,8 @@ class DevServer {
2118
2128
  context.settings.layout_style = 'full-width';
2119
2129
  }
2120
2130
 
2121
- const page = pageType === 'home' ? 'home' : (pageType === 'products' ? 'products' : 'home');
2131
+ const pageIdMap = { home: 'home', product: 'product', collection: 'category', category: 'category', products: 'products', categories: 'categories', collections: 'categories', brand: 'brand', cart: 'cart', checkout: 'checkout', page: 'home' };
2132
+ const page = pageIdMap[pageType] || 'home';
2122
2133
  context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
2123
2134
  context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
2124
2135
 
@@ -2143,20 +2154,23 @@ class DevServer {
2143
2154
 
2144
2155
  try {
2145
2156
  const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
2146
- const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
2147
- context.categories = raw.map((c) => {
2157
+ const raw = catRes?.categories || catRes?.data?.categories || catRes?.data?.items || catRes?.items || (Array.isArray(catRes) ? catRes : []);
2158
+ context.categories = Array.isArray(raw) ? raw.map((c) => {
2148
2159
  const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().trim() || null;
2149
2160
  if (h) {
2150
2161
  c.handle = c.handle || h;
2151
2162
  c.slug = c.slug || h;
2152
2163
  }
2153
2164
  return c;
2154
- });
2165
+ }) : [];
2155
2166
  context.collections = context.categories;
2156
2167
  } catch (e) {
2157
2168
  context.categories = [];
2158
2169
  context.collections = [];
2159
2170
  }
2171
+ if (pageType === 'collections' && !context.collections) {
2172
+ context.collections = context.categories || [];
2173
+ }
2160
2174
 
2161
2175
  try {
2162
2176
  const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
@@ -2250,12 +2264,35 @@ class DevServer {
2250
2264
  }
2251
2265
  }
2252
2266
 
2267
+ // Align with mock: hide Splash on pages (show only in /dev); in mock only, hide Footer type in footer (only FooterMenu)
2268
+ this.filterWidgetsForDisplay(context.widgets, this.mode);
2269
+
2253
2270
  // Enrich widgets with products, categories, and brands data
2254
2271
  this.enrichWidgetsWithData(context);
2255
-
2272
+
2256
2273
  return context;
2257
2274
  }
2258
-
2275
+
2276
+ /**
2277
+ * Filter widgets for display: remove Splash from all sections (preview only in /dev).
2278
+ * In mock mode only, remove Footer type from footer section (only FooterMenu in footer). Real mode shows all footer widgets.
2279
+ */
2280
+ filterWidgetsForDisplay(widgets, mode) {
2281
+ if (!widgets || typeof widgets !== 'object') return;
2282
+ const isMock = mode !== 'real';
2283
+ Object.keys(widgets).forEach(section => {
2284
+ const list = widgets[section];
2285
+ if (!Array.isArray(list)) return;
2286
+ const filtered = list.filter((w) => {
2287
+ const type = (w.type || '').toLowerCase().trim();
2288
+ if (type === 'splash') return false;
2289
+ if (isMock && section === 'footer' && type === 'footer') return false;
2290
+ return true;
2291
+ });
2292
+ widgets[section] = filtered;
2293
+ });
2294
+ }
2295
+
2259
2296
  /**
2260
2297
  * Enrich widgets with actual data (products, categories, brands)
2261
2298
  * This is essential for product carousels, category lists, etc. to display content
@@ -2345,11 +2382,165 @@ class DevServer {
2345
2382
  enrichedCount++;
2346
2383
  console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
2347
2384
  }
2385
+
2386
+ // Enrich News widgets with items (use existing or default)
2387
+ if (type === 'news') {
2388
+ let items = widget.data.items || widget.data.newsItems || widget.data.content?.items || widget.data.content?.Items;
2389
+ if (!items || !Array.isArray(items) || items.length === 0) {
2390
+ items = this.getDefaultMockNewsItems();
2391
+ enrichedCount++;
2392
+ console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${items.length} news items`);
2393
+ }
2394
+ widget.data.items = items;
2395
+ widget.data.newsItems = items;
2396
+ widget.data.content = widget.data.content || {};
2397
+ widget.data.content.items = items;
2398
+ widget.data.content.Items = items;
2399
+ }
2348
2400
  });
2349
2401
  });
2350
2402
 
2351
2403
  console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
2352
2404
  }
2405
+
2406
+ /**
2407
+ * Enrich a single widget with products, categories, brands, or news items.
2408
+ * Used by /dev/widget/:type so the preview has data without being in context.widgets.
2409
+ */
2410
+ enrichSingleWidget(widget, context) {
2411
+ if (!widget) return;
2412
+ widget.data = widget.data || {};
2413
+ const type = (widget.type || '').toLowerCase();
2414
+ const products = context.products || [];
2415
+ const categories = context.categories || [];
2416
+ const brands = context.brands || [];
2417
+
2418
+ if (type.includes('product') || type === 'featuredproducts' || type === 'bestsellerproducts' || type === 'newproducts' || type === 'simpleproduct' || type === 'singleproduct') {
2419
+ const widgetProducts = this.getProductsForWidget(widget, products);
2420
+ const formatted = this.mode === 'real'
2421
+ ? this.normalizeProductsForRealMode(widgetProducts)
2422
+ : this.enrichProductsData(widgetProducts);
2423
+ widget.data.products = formatted;
2424
+ widget.data.Products = formatted;
2425
+ widget.products = formatted;
2426
+ widget.data.content = widget.data.content || {};
2427
+ widget.data.content.products = formatted;
2428
+ widget.data.content.Products = formatted;
2429
+ }
2430
+
2431
+ if (type === 'recentlyviewed' || type === 'recently-viewed') {
2432
+ const recent = products.slice(0, 6);
2433
+ const recentProducts = this.mode === 'real'
2434
+ ? this.normalizeProductsForRealMode(recent)
2435
+ : this.enrichProductsData(recent);
2436
+ widget.data.products = recentProducts;
2437
+ widget.data.Products = recentProducts;
2438
+ widget.products = recentProducts;
2439
+ widget.data.content = widget.data.content || {};
2440
+ widget.data.content.products = recentProducts;
2441
+ widget.data.content.Products = recentProducts;
2442
+ }
2443
+
2444
+ if (type.includes('category') || type === 'categorylist' || type === 'categorylistcarousel') {
2445
+ const widgetCategories = categories.slice(0, 10);
2446
+ widget.data.categories = widgetCategories;
2447
+ widget.data.Categories = widgetCategories;
2448
+ widget.categories = widgetCategories;
2449
+ widget.data.content = widget.data.content || {};
2450
+ widget.data.content.categories = widgetCategories;
2451
+ widget.data.content.Categories = widgetCategories;
2452
+ }
2453
+
2454
+ if (type.includes('brand') || type === 'brandcarousel' || type === 'brandlist') {
2455
+ widget.data.brands = brands;
2456
+ widget.data.Brands = brands;
2457
+ widget.brands = brands;
2458
+ widget.data.content = widget.data.content || {};
2459
+ widget.data.content.brands = brands;
2460
+ widget.data.content.Brands = brands;
2461
+ }
2462
+
2463
+ if (type === 'news') {
2464
+ let items = widget.data.items || widget.data.newsItems || widget.data.content?.items || widget.data.content?.Items;
2465
+ if (!items || !Array.isArray(items) || items.length === 0) {
2466
+ items = context.mockNewsItems || this.getDefaultMockNewsItems();
2467
+ }
2468
+ widget.data.items = items;
2469
+ widget.data.newsItems = items;
2470
+ widget.data.content = widget.data.content || {};
2471
+ widget.data.content.items = items;
2472
+ widget.data.content.Items = items;
2473
+ }
2474
+ }
2475
+
2476
+ /** Default news items for dev preview when no mock data is present */
2477
+ getDefaultMockNewsItems() {
2478
+ return [
2479
+ { id: 1, title: 'Welcome to Our Store', slug: 'welcome', excerpt: 'Learn about our new collection and special offers.', thumbnailImage: { url: 'https://picsum.photos/seed/n1/400/220' }, publishedAt: new Date().toISOString() },
2480
+ { id: 2, title: 'Summer Sale 2025', slug: 'summer-sale', excerpt: 'Up to 50% off on selected items.', thumbnailImage: { url: 'https://picsum.photos/seed/n2/400/220' }, publishedAt: new Date().toISOString() },
2481
+ { id: 3, title: 'New Arrivals', slug: 'new-arrivals', excerpt: 'Check out the latest products in our catalog.', thumbnailImage: { url: 'https://picsum.photos/seed/n3/400/220' }, publishedAt: new Date().toISOString() },
2482
+ { id: 4, title: 'Tips & Guides', slug: 'tips-guides', excerpt: 'How to get the most from your purchase.', thumbnailImage: { url: 'https://picsum.photos/seed/n4/400/220' }, publishedAt: new Date().toISOString() }
2483
+ ];
2484
+ }
2485
+
2486
+ /**
2487
+ * Build mock context for snippet preview so all snippets have data to render.
2488
+ * Ensures product, collection, cart, news_item, pagination, page, etc. are present.
2489
+ */
2490
+ getSnippetMockContext(snippetName, baseContext) {
2491
+ const ctx = { ...baseContext };
2492
+ const product = ctx.products?.[0] || ctx.product;
2493
+ const collection = ctx.collections?.[0] || ctx.collection || ctx.categories?.[0];
2494
+ const cart = ctx.cart || { items: [], itemCount: 0, total: 0, subTotal: 0 };
2495
+
2496
+ if (product) {
2497
+ ctx.product = this.mode === 'real' ? product : this.enrichProductData(product);
2498
+ if (!ctx.product.prices) {
2499
+ ctx.product.prices = { price: ctx.product.price || 0, mrp: ctx.product.compareAtPrice || 0, priceString: `$${Number(ctx.product.price || 0).toFixed(2)}`, mrpString: ctx.product.compareAtPrice ? `$${Number(ctx.product.compareAtPrice).toFixed(2)}` : '' };
2500
+ }
2501
+ if (!ctx.product.variations && !ctx.product.variants) {
2502
+ ctx.product.variations = [{ id: 1, productId: ctx.product.id, price: ctx.product.price, inStock: true, title: 'Default' }];
2503
+ ctx.product.variants = ctx.product.variations;
2504
+ }
2505
+ } else {
2506
+ const mockProduct = { id: 1, title: 'Sample Product', name: 'Sample Product', handle: 'sample-product', slug: 'sample-product', url: '/products/sample-product', price: 29.99, compareAtPrice: 39.99, imageUrl: 'https://picsum.photos/seed/p1/400/400', thumbnailImage: 'https://picsum.photos/seed/p1/400/400', prices: { price: 29.99, mrp: 39.99, priceString: '$29.99', mrpString: '$39.99' }, variations: [{ id: 1, productId: 1, price: 29.99, inStock: true, title: 'Default' }], variants: null, rating: 4.5, averageRating: 4.5, reviewCount: 12, reviewsCount: 12 };
2507
+ mockProduct.variants = mockProduct.variations;
2508
+ ctx.product = mockProduct;
2509
+ }
2510
+ if (ctx.product && (ctx.product.rating == null && ctx.product.averageRating == null)) {
2511
+ ctx.product.rating = ctx.product.rating ?? 4.5;
2512
+ ctx.product.averageRating = ctx.product.averageRating ?? ctx.product.rating;
2513
+ }
2514
+ if (ctx.product && (ctx.product.reviewCount == null && ctx.product.reviewsCount == null)) {
2515
+ ctx.product.reviewCount = ctx.product.reviewCount ?? 12;
2516
+ ctx.product.reviewsCount = ctx.product.reviewsCount ?? ctx.product.reviewCount;
2517
+ }
2518
+
2519
+ if (collection) {
2520
+ ctx.collection = { ...collection, title: collection.title || collection.name, handle: collection.handle || collection.slug, url: `/categories/${collection.handle || collection.slug}` };
2521
+ } else {
2522
+ ctx.collection = { id: 1, title: 'Sample Collection', name: 'Sample Collection', handle: 'sample', slug: 'sample', url: '/categories/sample' };
2523
+ }
2524
+
2525
+ ctx.cart = cart && typeof cart === 'object' ? cart : { items: [], itemCount: 0, total: 0, subTotal: 0 };
2526
+ if (!ctx.cart.items) ctx.cart.items = [];
2527
+ if (ctx.cart.items.length === 0 && ctx.product) {
2528
+ ctx.cart.items = [{ id: '1', productId: ctx.product.id, title: ctx.product.title || ctx.product.name, quantity: 1, price: ctx.product.price, image: ctx.product.imageUrl || ctx.product.thumbnailImage }];
2529
+ ctx.cart.itemCount = 1;
2530
+ ctx.cart.total = ctx.product.price;
2531
+ ctx.cart.subTotal = ctx.product.price;
2532
+ }
2533
+
2534
+ ctx.news_item = ctx.news_item || this.getDefaultMockNewsItems()[0];
2535
+ ctx.pagination = ctx.pagination || { totalPages: 5, currentPage: 1, total: 50, pageSize: 10 };
2536
+ ctx.page = ctx.page || { title: 'Sample Page', handle: 'sample-page' };
2537
+ ctx.article = ctx.article || null;
2538
+ ctx.line_item = ctx.cart.items?.[0] || null;
2539
+ if (ctx.shop && !ctx.shop.currency) ctx.shop.currency = 'USD';
2540
+ if (ctx.shop && !ctx.shop.settings) ctx.shop.settings = { currencySymbol: '$' };
2541
+
2542
+ return ctx;
2543
+ }
2353
2544
 
2354
2545
  /**
2355
2546
  * Check if a product belongs to a category (for collection/category filtering).
@@ -2467,35 +2658,58 @@ class DevServer {
2467
2658
  normalizeProductForRealMode(p) {
2468
2659
  if (!p || typeof p !== 'object') return p;
2469
2660
  const n = { ...p };
2661
+ const apiBase = (process.env.O2VEND_API_BASE_URL || '').replace(/\/$/, '');
2662
+
2470
2663
  if (n.Description != null && n.description == null) n.description = n.Description;
2471
2664
  if (n.HtmlContent != null && n.htmlContent == null) n.htmlContent = n.HtmlContent;
2472
2665
  if (n.ShortDescription != null && n.shortDescription == null) n.shortDescription = n.ShortDescription;
2473
2666
  if (n.Name != null && n.name == null) n.name = n.Name;
2474
2667
  if (n.Title != null && n.title == null) n.title = n.Title;
2475
2668
  if (n.Attributes != null && n.attributes == null) n.attributes = n.Attributes;
2669
+ if (n.Variations != null && n.variations == null) n.variations = n.Variations;
2670
+ if (n.Combinations != null && n.combinations == null) n.combinations = n.Combinations;
2671
+ if (n.Subscriptions != null && n.subscriptions == null) n.subscriptions = n.Subscriptions;
2672
+ if (n.ProductType != null && n.productType == null) n.productType = n.ProductType;
2673
+
2674
+ const resolveImageUrl = (u) => {
2675
+ if (!u || typeof u !== 'string') return u;
2676
+ if (u.startsWith('http://') || u.startsWith('https://') || u.startsWith('//')) return u;
2677
+ if (u.startsWith('/') && apiBase) return apiBase + u;
2678
+ return u;
2679
+ };
2476
2680
 
2477
- const t1 = n.ThumbnailImage1 || n.thumbnailImage || n.thumbnailImage;
2681
+ const extractUrl = (obj) => {
2682
+ if (!obj) return null;
2683
+ if (typeof obj === 'string') return obj;
2684
+ return obj.url || obj.Url || obj.imageUrl || obj.ImageUrl || obj.fileName || obj.FileName || obj.filePath || obj.FilePath || obj.imagePath || obj.ImagePath || null;
2685
+ };
2686
+
2687
+ const t1 = n.ThumbnailImage1 || n.thumbnailImage1 || n.thumbnailImage;
2478
2688
  if (t1 != null) {
2479
- const u = typeof t1 === 'string' ? t1 : (t1.url || t1.Url || t1.imageUrl || t1.ImageUrl);
2689
+ const u = resolveImageUrl(extractUrl(t1));
2480
2690
  if (u) {
2481
2691
  n.thumbnailImage = u;
2482
2692
  if (!n.imageUrl) n.imageUrl = u;
2483
2693
  }
2484
2694
  }
2485
2695
 
2486
- let imgs = n.images || n.Images;
2696
+ let imgs = n.images || n.Images || n.ProductImages || n.productImages;
2487
2697
  if (Array.isArray(imgs) && imgs.length > 0) {
2488
2698
  n.images = imgs.map((img) => {
2489
- if (typeof img === 'string') return { url: img, altText: n.name || n.title };
2490
- const u = img.url || img.Url || img.imageUrl || img.ImageUrl;
2491
- const a = img.altText || img.AltText || n.name || n.title;
2699
+ const u = resolveImageUrl(typeof img === 'string' ? img : extractUrl(img));
2700
+ const a = (typeof img === 'object' ? (img.altText || img.AltText) : null) || n.name || n.title;
2492
2701
  return { url: u || '', altText: a };
2493
2702
  });
2494
2703
  } else {
2495
- const single = n.imageUrl || n.ImageUrl;
2704
+ const single = resolveImageUrl(n.imageUrl || n.ImageUrl);
2496
2705
  if (single) n.images = [{ url: single, altText: n.name || n.title }];
2497
2706
  }
2498
2707
 
2708
+ if (n.images && n.images.length > 0) {
2709
+ n.featured_image = n.images[0];
2710
+ if (!n.imageUrl) n.imageUrl = n.images[0].url;
2711
+ }
2712
+
2499
2713
  const handle = (n.handle || n.slug || n.id || '').toString().trim();
2500
2714
  const h = handle && handle !== '#' ? handle.toLowerCase() : (n.productId ? String(n.productId) : null);
2501
2715
  if (h) {
@@ -2854,10 +3068,14 @@ class DevServer {
2854
3068
 
2855
3069
  try {
2856
3070
  const templateSlug = this.widgetService.getTemplateSlug(widget.type);
2857
- const widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
2858
-
3071
+ let widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
2859
3072
  if (!fs.existsSync(widgetPath)) {
2860
- return `<div class="error">Widget template not found: ${templateSlug}.liquid</div>`;
3073
+ const sharedPath = path.join(this.themePath, 'widgets', 'shared', `${templateSlug}.liquid`);
3074
+ if (fs.existsSync(sharedPath)) {
3075
+ widgetPath = sharedPath;
3076
+ } else {
3077
+ return `<div class="error">Widget template not found: ${templateSlug}.liquid</div>`;
3078
+ }
2861
3079
  }
2862
3080
 
2863
3081
  const widgetContent = fs.readFileSync(widgetPath, 'utf8');
@@ -3099,7 +3317,7 @@ class DevServer {
3099
3317
  </div>
3100
3318
  </header>
3101
3319
 
3102
- <div class="section">
3320
+ <div class="section" id="templates">
3103
3321
  <h2>📄 Templates <span class="section-count">(${templates.length})</span></h2>
3104
3322
  ${templates.length > 0 ? `
3105
3323
  <div class="file-grid">
@@ -3113,7 +3331,7 @@ class DevServer {
3113
3331
  ` : '<div class="empty">No templates found</div>'}
3114
3332
  </div>
3115
3333
 
3116
- <div class="section">
3334
+ <div class="section" id="sections">
3117
3335
  <h2>🧩 Sections <span class="section-count">(${sections.length})</span></h2>
3118
3336
  ${sections.length > 0 ? `
3119
3337
  <div class="file-grid">
@@ -3127,7 +3345,7 @@ class DevServer {
3127
3345
  ` : '<div class="empty">No sections found</div>'}
3128
3346
  </div>
3129
3347
 
3130
- <div class="section">
3348
+ <div class="section" id="widgets">
3131
3349
  <h2>🎯 Widgets <span class="section-count">(${widgets.length})</span></h2>
3132
3350
  ${widgets.length > 0 ? `
3133
3351
  <div class="file-grid">
@@ -3141,7 +3359,7 @@ class DevServer {
3141
3359
  ` : '<div class="empty">No widgets found</div>'}
3142
3360
  </div>
3143
3361
 
3144
- <div class="section">
3362
+ <div class="section" id="snippets">
3145
3363
  <h2>✂️ Snippets <span class="section-count">(${snippets.length})</span></h2>
3146
3364
  ${snippets.length > 0 ? `
3147
3365
  <div class="file-grid">
@@ -3163,14 +3381,91 @@ class DevServer {
3163
3381
  <p style="margin-top: 8px; font-size: 0.85em; opacity: 0.7;">Version 1.0.0 | Running on port ${this.port || 3000}</p>
3164
3382
  </div>
3165
3383
  </div>
3384
+ <script>
3385
+ (function() {
3386
+ var hash = window.location.hash;
3387
+ if (hash) {
3388
+ var el = document.querySelector(hash);
3389
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
3390
+ }
3391
+ })();
3392
+ </script>
3166
3393
  </body>
3167
3394
  </html>`;
3168
3395
  }
3169
3396
 
3397
+ /**
3398
+ * Return link tags for theme CSS files that exist (so snippet/section preview gets theme styles).
3399
+ * Includes theme, components, sections, and any other .css in assets (e.g. delivery-zone.css).
3400
+ */
3401
+ getThemeCssLinkTags() {
3402
+ const assetsDir = path.join(this.themePath, 'assets');
3403
+ const links = [];
3404
+ const seen = new Set();
3405
+ const add = (base) => {
3406
+ if (seen.has(base)) return;
3407
+ seen.add(base);
3408
+ const minPath = path.join(assetsDir, `${base}.min.css`);
3409
+ const cssPath = path.join(assetsDir, `${base}.css`);
3410
+ if (fs.existsSync(minPath)) {
3411
+ links.push(`<link rel="stylesheet" href="/assets/${base}.min.css">`);
3412
+ } else if (fs.existsSync(cssPath)) {
3413
+ links.push(`<link rel="stylesheet" href="/assets/${base}.css">`);
3414
+ }
3415
+ };
3416
+ ['theme', 'components', 'sections'].forEach(add);
3417
+ if (fs.existsSync(assetsDir)) {
3418
+ try {
3419
+ fs.readdirSync(assetsDir)
3420
+ .filter((f) => f.endsWith('.css') && !f.endsWith('.min.css'))
3421
+ .forEach((f) => add(f.replace(/\.css$/, '')));
3422
+ } catch (_) { /* ignore */ }
3423
+ }
3424
+ return links.join('\n ');
3425
+ }
3426
+
3170
3427
  /**
3171
3428
  * Generate preview page HTML
3172
3429
  */
3173
- generatePreviewPage({ title, content, type, name, backUrl }) {
3430
+ generatePreviewPage({ title, content, type, name, backUrl, includeThemeCss = false }) {
3431
+ const themeCssLinks = includeThemeCss ? this.getThemeCssLinkTags() : '';
3432
+ const modalSnippets = ['login-modal', 'delivery-zone-modal', 'add-to-cart-modal'];
3433
+ const isModalSnippet = type === 'snippet' && name && modalSnippets.includes(name);
3434
+ const modalVisibilityCss = isModalSnippet ? `
3435
+ /* Force modal visible in /dev snippet preview (no trigger to open; overrides [aria-hidden="true"] { display: none }) */
3436
+ .preview-container .login-modal,
3437
+ .preview-container .login-modal[aria-hidden="true"],
3438
+ .preview-container .login-modal.login-modal--active {
3439
+ display: flex !important;
3440
+ align-items: center;
3441
+ justify-content: center;
3442
+ position: fixed;
3443
+ inset: 0;
3444
+ z-index: 9999;
3445
+ visibility: visible !important;
3446
+ }
3447
+ .preview-container .delivery-zone-modal,
3448
+ .preview-container .delivery-zone-modal[aria-hidden="true"],
3449
+ .preview-container .delivery-zone-modal.active {
3450
+ display: flex !important;
3451
+ align-items: center;
3452
+ justify-content: center;
3453
+ position: fixed;
3454
+ inset: 0;
3455
+ z-index: 9999;
3456
+ visibility: visible !important;
3457
+ }
3458
+ .preview-container .add-to-cart-modal,
3459
+ .preview-container .add-to-cart-modal[aria-hidden="true"] {
3460
+ display: flex !important;
3461
+ align-items: center;
3462
+ justify-content: center;
3463
+ position: fixed;
3464
+ inset: 0;
3465
+ z-index: 9999;
3466
+ visibility: visible !important;
3467
+ }
3468
+ ` : '';
3174
3469
  return `<!DOCTYPE html>
3175
3470
  <html lang="en">
3176
3471
  <head>
@@ -3178,7 +3473,7 @@ class DevServer {
3178
3473
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3179
3474
  <title>${title} - O2VEND Dev</title>
3180
3475
  <link rel="icon" type="image/png" href="/o2vend-assets/favicon.png">
3181
- <style>
3476
+ ${themeCssLinks ? ` ${themeCssLinks}\n ` : ''}<style>
3182
3477
  * { margin: 0; padding: 0; box-sizing: border-box; }
3183
3478
  :root {
3184
3479
  --o2vend-primary: #F9A825;
@@ -3251,6 +3546,7 @@ class DevServer {
3251
3546
  border-radius: 6px;
3252
3547
  color: #c62828;
3253
3548
  }
3549
+ ${modalVisibilityCss}
3254
3550
  </style>
3255
3551
  </head>
3256
3552
  <body>
@@ -233,9 +233,11 @@ function registerCustomTags(liquid, themePath) {
233
233
  if (widget.template_path) {
234
234
  const widgetPath = path.join(themePath, `${widget.template_path}.liquid`);
235
235
  const altPath = path.join(themePath, widget.template_path);
236
+ const baseName = (widget.template_path.replace(/^widgets\//, '').replace(/\.liquid$/, '') || widget.template_path).split('/').pop();
237
+ const sharedPath = path.join(themePath, 'widgets', 'shared', `${baseName}.liquid`);
236
238
 
237
239
  const finalPath = fs.existsSync(widgetPath) ? widgetPath :
238
- (fs.existsSync(altPath) ? altPath : null);
240
+ (fs.existsSync(altPath) ? altPath : (fs.existsSync(sharedPath) ? sharedPath : null));
239
241
 
240
242
  if (finalPath) {
241
243
  const fullContext = currentRenderingContext