@o2vend/theme-cli 1.0.37 → 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 (89) hide show
  1. package/lib/lib/dev-server.js +309 -40
  2. package/lib/lib/liquid-engine.js +3 -1
  3. package/lib/lib/mock-data.js +36 -124
  4. package/lib/lib/widget-service.js +12 -4
  5. package/package.json +1 -1
  6. package/test-theme/assets/async-sections.js +32 -24
  7. package/test-theme/assets/cart-drawer.js +20 -22
  8. package/test-theme/assets/cart-manager.js +1 -15
  9. package/test-theme/assets/checkout-price-handler.js +12 -11
  10. package/test-theme/assets/checkout.css +1415 -0
  11. package/test-theme/assets/checkout.js +3174 -0
  12. package/test-theme/assets/components.css +178 -29
  13. package/test-theme/assets/delivery-zone.js +1 -1
  14. package/test-theme/assets/product-detail.css +1050 -0
  15. package/test-theme/assets/product-detail.js +2940 -0
  16. package/test-theme/assets/theme.css +95 -120
  17. package/test-theme/assets/theme.js +781 -186
  18. package/test-theme/layout/theme.liquid +91 -17
  19. package/test-theme/sections/content.liquid +64 -57
  20. package/test-theme/sections/footer-fallback.liquid +57 -7
  21. package/test-theme/sections/footer.liquid +63 -12
  22. package/test-theme/sections/header-fallback.liquid +41 -41
  23. package/test-theme/sections/header.liquid +41 -51
  24. package/test-theme/sections/hero-fallback.liquid +1 -1
  25. package/test-theme/sections/hero.liquid +159 -136
  26. package/test-theme/snippets/account-sidebar.liquid +121 -29
  27. package/test-theme/snippets/add-to-cart-modal.liquid +258 -206
  28. package/test-theme/snippets/breadcrumbs.liquid +98 -11
  29. package/test-theme/snippets/cart-drawer.liquid +93 -0
  30. package/test-theme/snippets/delivery-zone-city-selector.liquid +101 -15
  31. package/test-theme/snippets/delivery-zone-modal.liquid +529 -84
  32. package/test-theme/snippets/delivery-zone-search.liquid +104 -18
  33. package/test-theme/snippets/login-modal.liquid +269 -82
  34. package/test-theme/snippets/mega-menu.liquid +130 -43
  35. package/test-theme/snippets/news-thumbnail.liquid +120 -28
  36. package/test-theme/snippets/pagination.liquid +1 -1
  37. package/test-theme/snippets/price.liquid +100 -9
  38. package/test-theme/snippets/product-card-related.liquid +22 -4
  39. package/test-theme/snippets/product-card-simple.liquid +521 -25
  40. package/test-theme/snippets/product-card.liquid +145 -232
  41. package/test-theme/snippets/rating.liquid +100 -9
  42. package/test-theme/snippets/skeleton-collection-grid.liquid +94 -8
  43. package/test-theme/snippets/skeleton-product-card.liquid +102 -16
  44. package/test-theme/snippets/skeleton-product-grid.liquid +87 -1
  45. package/test-theme/snippets/social-sharing.liquid +133 -32
  46. package/test-theme/templates/account/dashboard.liquid +30 -0
  47. package/test-theme/templates/account/loyalty-redemption.liquid +29 -28
  48. package/test-theme/templates/account/loyalty.liquid +45 -43
  49. package/test-theme/templates/account/order-detail.liquid +15 -8
  50. package/test-theme/templates/account/orders.liquid +189 -35
  51. package/test-theme/templates/account/profile.liquid +509 -114
  52. package/test-theme/templates/account/register.liquid +18 -8
  53. package/test-theme/templates/account/return-orders.liquid +31 -30
  54. package/test-theme/templates/account/store-credit.liquid +27 -26
  55. package/test-theme/templates/account/subscriptions.liquid +22 -5
  56. package/test-theme/templates/account/wishlist.liquid +88 -19
  57. package/test-theme/templates/address-book.liquid +166 -69
  58. package/test-theme/templates/categories.liquid +90 -30
  59. package/test-theme/templates/checkout.liquid +137 -3834
  60. package/test-theme/templates/error.liquid +23 -21
  61. package/test-theme/templates/index.liquid +29 -0
  62. package/test-theme/templates/login.liquid +33 -6
  63. package/test-theme/templates/order-confirmation.liquid +67 -9
  64. package/test-theme/templates/page.liquid +418 -206
  65. package/test-theme/templates/product-detail.liquid +124 -3878
  66. package/test-theme/templates/products.liquid +155 -30
  67. package/test-theme/templates/search.liquid +739 -225
  68. package/test-theme/widgets/brand-carousel.liquid +102 -82
  69. package/test-theme/widgets/brand.liquid +78 -50
  70. package/test-theme/widgets/carousel.liquid +253 -121
  71. package/test-theme/widgets/category-list-carousel.liquid +32 -8
  72. package/test-theme/widgets/category-list.liquid +21 -6
  73. package/test-theme/widgets/category.liquid +104 -37
  74. package/test-theme/widgets/discount-time.liquid +326 -119
  75. package/test-theme/widgets/footer-menu.liquid +115 -23
  76. package/test-theme/widgets/footer.liquid +118 -5
  77. package/test-theme/widgets/gallery.liquid +29 -5
  78. package/test-theme/widgets/header-menu.liquid +25 -13
  79. package/test-theme/widgets/header.liquid +64 -26
  80. package/test-theme/widgets/html.liquid +29 -6
  81. package/test-theme/widgets/news.liquid +6 -0
  82. package/test-theme/widgets/product-canvas.liquid +20 -12
  83. package/test-theme/widgets/product-carousel.liquid +118 -56
  84. package/test-theme/widgets/shared/product-grid.liquid +12 -0
  85. package/test-theme/widgets/single-product.liquid +688 -250
  86. package/test-theme/widgets/spacebar-carousel.liquid +39 -10
  87. package/test-theme/widgets/spacebar.liquid +77 -6
  88. package/test-theme/widgets/splash.liquid +40 -30
  89. package/test-theme/widgets/testimonial-carousel.liquid +111 -67
@@ -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 {
@@ -2121,7 +2128,7 @@ class DevServer {
2121
2128
  context.settings.layout_style = 'full-width';
2122
2129
  }
2123
2130
 
2124
- const pageIdMap = { home: 'home', product: 'product', collection: 'category', category: 'category', products: 'products', categories: 'categories', brand: 'brand', checkout: 'checkout', page: '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' };
2125
2132
  const page = pageIdMap[pageType] || 'home';
2126
2133
  context.widgets = await webstoreApi.fetchWidgetsBySections(page, webstoreApi.DEFAULT_SECTIONS, opts);
2127
2134
  context.widgets = context.widgets || { hero: [], products: [], footer: [], content: [], header: [] };
@@ -2147,20 +2154,23 @@ class DevServer {
2147
2154
 
2148
2155
  try {
2149
2156
  const catRes = await webstoreApi.fetchCategories({ limit: 50, offset: 0 }, opts);
2150
- const raw = catRes?.categories || catRes?.data?.categories || catRes || [];
2151
- 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) => {
2152
2159
  const h = (c.handle || (c.name || '').toLowerCase().replace(/\s+/g, '-') || c.id || '').toString().trim() || null;
2153
2160
  if (h) {
2154
2161
  c.handle = c.handle || h;
2155
2162
  c.slug = c.slug || h;
2156
2163
  }
2157
2164
  return c;
2158
- });
2165
+ }) : [];
2159
2166
  context.collections = context.categories;
2160
2167
  } catch (e) {
2161
2168
  context.categories = [];
2162
2169
  context.collections = [];
2163
2170
  }
2171
+ if (pageType === 'collections' && !context.collections) {
2172
+ context.collections = context.categories || [];
2173
+ }
2164
2174
 
2165
2175
  try {
2166
2176
  const brandRes = await webstoreApi.fetchBrands({ limit: 50, offset: 0 }, opts);
@@ -2254,12 +2264,35 @@ class DevServer {
2254
2264
  }
2255
2265
  }
2256
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
+
2257
2270
  // Enrich widgets with products, categories, and brands data
2258
2271
  this.enrichWidgetsWithData(context);
2259
-
2272
+
2260
2273
  return context;
2261
2274
  }
2262
-
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
+
2263
2296
  /**
2264
2297
  * Enrich widgets with actual data (products, categories, brands)
2265
2298
  * This is essential for product carousels, category lists, etc. to display content
@@ -2349,11 +2382,165 @@ class DevServer {
2349
2382
  enrichedCount++;
2350
2383
  console.log(`[ENRICH] ${widget.type} (${widget.id}): Added ${recentProducts.length} recently viewed products`);
2351
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
+ }
2352
2400
  });
2353
2401
  });
2354
2402
 
2355
2403
  console.log(`[CONTEXT] ✅ Enriched ${enrichedCount} widgets with data`);
2356
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
+ }
2357
2544
 
2358
2545
  /**
2359
2546
  * Check if a product belongs to a category (for collection/category filtering).
@@ -2881,10 +3068,14 @@ class DevServer {
2881
3068
 
2882
3069
  try {
2883
3070
  const templateSlug = this.widgetService.getTemplateSlug(widget.type);
2884
- const widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
2885
-
3071
+ let widgetPath = path.join(this.themePath, 'widgets', `${templateSlug}.liquid`);
2886
3072
  if (!fs.existsSync(widgetPath)) {
2887
- 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
+ }
2888
3079
  }
2889
3080
 
2890
3081
  const widgetContent = fs.readFileSync(widgetPath, 'utf8');
@@ -3126,7 +3317,7 @@ class DevServer {
3126
3317
  </div>
3127
3318
  </header>
3128
3319
 
3129
- <div class="section">
3320
+ <div class="section" id="templates">
3130
3321
  <h2>📄 Templates <span class="section-count">(${templates.length})</span></h2>
3131
3322
  ${templates.length > 0 ? `
3132
3323
  <div class="file-grid">
@@ -3140,7 +3331,7 @@ class DevServer {
3140
3331
  ` : '<div class="empty">No templates found</div>'}
3141
3332
  </div>
3142
3333
 
3143
- <div class="section">
3334
+ <div class="section" id="sections">
3144
3335
  <h2>🧩 Sections <span class="section-count">(${sections.length})</span></h2>
3145
3336
  ${sections.length > 0 ? `
3146
3337
  <div class="file-grid">
@@ -3154,7 +3345,7 @@ class DevServer {
3154
3345
  ` : '<div class="empty">No sections found</div>'}
3155
3346
  </div>
3156
3347
 
3157
- <div class="section">
3348
+ <div class="section" id="widgets">
3158
3349
  <h2>🎯 Widgets <span class="section-count">(${widgets.length})</span></h2>
3159
3350
  ${widgets.length > 0 ? `
3160
3351
  <div class="file-grid">
@@ -3168,7 +3359,7 @@ class DevServer {
3168
3359
  ` : '<div class="empty">No widgets found</div>'}
3169
3360
  </div>
3170
3361
 
3171
- <div class="section">
3362
+ <div class="section" id="snippets">
3172
3363
  <h2>✂️ Snippets <span class="section-count">(${snippets.length})</span></h2>
3173
3364
  ${snippets.length > 0 ? `
3174
3365
  <div class="file-grid">
@@ -3190,14 +3381,91 @@ class DevServer {
3190
3381
  <p style="margin-top: 8px; font-size: 0.85em; opacity: 0.7;">Version 1.0.0 | Running on port ${this.port || 3000}</p>
3191
3382
  </div>
3192
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>
3193
3393
  </body>
3194
3394
  </html>`;
3195
3395
  }
3196
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
+
3197
3427
  /**
3198
3428
  * Generate preview page HTML
3199
3429
  */
3200
- 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
+ ` : '';
3201
3469
  return `<!DOCTYPE html>
3202
3470
  <html lang="en">
3203
3471
  <head>
@@ -3205,7 +3473,7 @@ class DevServer {
3205
3473
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
3206
3474
  <title>${title} - O2VEND Dev</title>
3207
3475
  <link rel="icon" type="image/png" href="/o2vend-assets/favicon.png">
3208
- <style>
3476
+ ${themeCssLinks ? ` ${themeCssLinks}\n ` : ''}<style>
3209
3477
  * { margin: 0; padding: 0; box-sizing: border-box; }
3210
3478
  :root {
3211
3479
  --o2vend-primary: #F9A825;
@@ -3278,6 +3546,7 @@ class DevServer {
3278
3546
  border-radius: 6px;
3279
3547
  color: #c62828;
3280
3548
  }
3549
+ ${modalVisibilityCss}
3281
3550
  </style>
3282
3551
  </head>
3283
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