@osimart/create-storefront 0.0.0-stage → 1.0.1

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 (206) hide show
  1. package/README.md +80 -2
  2. package/THEME_DEV_API.md +80 -0
  3. package/app/app.vue +154 -0
  4. package/app/assets/css/osi-controls.css +824 -0
  5. package/app/components/SectionRenderer.vue +46 -0
  6. package/app/components/builder/BuilderOverlay.vue +582 -0
  7. package/app/components/checkout/ExtraFees.vue +329 -0
  8. package/app/components/checkout/PaymentHandoff.vue +388 -0
  9. package/app/components/common/Banner.vue +35 -0
  10. package/app/components/common/ConfirmDeleteModal.vue +117 -0
  11. package/app/components/common/DropdownSelect.vue +432 -0
  12. package/app/components/common/Loader.vue +32 -0
  13. package/app/components/common/OsiField.vue +218 -0
  14. package/app/components/common/PhoneNumInput.vue +434 -0
  15. package/app/composables/UseBodyScrollLock.js +122 -0
  16. package/app/composables/UseThemeGroup.js +575 -0
  17. package/app/composables/useAddToCart.js +87 -0
  18. package/app/composables/useAnalytics.js +15 -0
  19. package/app/composables/useAnnouncements.js +29 -0
  20. package/app/composables/useApiErrorToast.js +104 -0
  21. package/app/composables/useAuth.js +99 -0
  22. package/app/composables/useBanners.js +25 -0
  23. package/app/composables/useBuilderPreview.js +320 -0
  24. package/app/composables/useButtonStyle.js +107 -0
  25. package/app/composables/useCarousel.js +160 -0
  26. package/app/composables/useCart.js +109 -0
  27. package/app/composables/useCheckout.js +76 -0
  28. package/app/composables/useClassificationItems.js +269 -0
  29. package/app/composables/useClassificationPage.js +545 -0
  30. package/app/composables/useClassificationUrl.js +63 -0
  31. package/app/composables/useColorScheme.js +44 -0
  32. package/app/composables/useContactForm.js +53 -0
  33. package/app/composables/useContentTree.js +770 -0
  34. package/app/composables/useDragScroll.js +73 -0
  35. package/app/composables/useFooterSocials.js +107 -0
  36. package/app/composables/useInputStyle.js +45 -0
  37. package/app/composables/useLayoutShell.js +114 -0
  38. package/app/composables/useLazyVideo.js +36 -0
  39. package/app/composables/useMediaTypes.js +66 -0
  40. package/app/composables/useMediaUrl.js +71 -0
  41. package/app/composables/useMenu.js +100 -0
  42. package/app/composables/useNavStyle.js +219 -0
  43. package/app/composables/usePlaceholders.js +294 -0
  44. package/app/composables/usePolicies.js +26 -0
  45. package/app/composables/useProductCardMedia.js +135 -0
  46. package/app/composables/useProductCardStyle.js +132 -0
  47. package/app/composables/useProductDetail.js +982 -0
  48. package/app/composables/useProductGallery.js +166 -0
  49. package/app/composables/useProductItems.js +270 -0
  50. package/app/composables/useProductUrl.js +60 -0
  51. package/app/composables/useProductVariants.js +210 -0
  52. package/app/composables/useQuickShop.js +115 -0
  53. package/app/composables/useRelatedProducts.js +186 -0
  54. package/app/composables/useRenderContext.js +45 -0
  55. package/app/composables/useScopedCss.js +106 -0
  56. package/app/composables/useSearch.js +35 -0
  57. package/app/composables/useSectionData.js +462 -0
  58. package/app/composables/useSectionShell.js +113 -0
  59. package/app/composables/useSidebar.js +69 -0
  60. package/app/composables/useStoreData.js +57 -0
  61. package/app/composables/useStoreInit.js +52 -0
  62. package/app/composables/useStorefront.js +239 -0
  63. package/app/composables/useT.js +21 -0
  64. package/app/composables/useTextStyles.js +84 -0
  65. package/app/composables/useThemeControls.js +34 -0
  66. package/app/composables/useThemeSeo.js +91 -0
  67. package/app/composables/useThemeTokens.js +171 -0
  68. package/app/composables/useWishlist.js +66 -0
  69. package/app/logic/components/AnnouncementBar.js +118 -0
  70. package/app/logic/components/CardGrid.js +125 -0
  71. package/app/logic/components/CardList.js +98 -0
  72. package/app/logic/components/CartDrawer.js +273 -0
  73. package/app/logic/components/CartItem.js +31 -0
  74. package/app/logic/components/ClassificationBento.js +6 -0
  75. package/app/logic/components/ClassificationCard.js +6 -0
  76. package/app/logic/components/ClassificationCarousel.js +115 -0
  77. package/app/logic/components/ClassificationEditorial.js +41 -0
  78. package/app/logic/components/ClassificationFeatured.js +57 -0
  79. package/app/logic/components/ClassificationGrid.js +6 -0
  80. package/app/logic/components/ClassificationNav.js +61 -0
  81. package/app/logic/components/ClassificationPart.js +72 -0
  82. package/app/logic/components/FooterBlock.js +91 -0
  83. package/app/logic/components/FooterSocials.js +60 -0
  84. package/app/logic/components/HeaderDropdownList.js +29 -0
  85. package/app/logic/components/HeaderIcon.js +68 -0
  86. package/app/logic/components/HeaderLogo.js +39 -0
  87. package/app/logic/components/HeaderNav.js +268 -0
  88. package/app/logic/components/MobileMenu.js +280 -0
  89. package/app/logic/components/ProductBuyButtons.js +66 -0
  90. package/app/logic/components/ProductCard.js +160 -0
  91. package/app/logic/components/ProductFilters.js +37 -0
  92. package/app/logic/components/ProductGallery.js +19 -0
  93. package/app/logic/components/ProductInfoBlock.js +411 -0
  94. package/app/logic/components/ProductLightbox.js +12 -0
  95. package/app/logic/components/ProductQuantity.js +31 -0
  96. package/app/logic/components/ProductVariantPicker.js +125 -0
  97. package/app/logic/components/QuickBuy.js +194 -0
  98. package/app/logic/components/SearchOverlay.js +41 -0
  99. package/app/logic/components/SidebarMenuItem.js +39 -0
  100. package/app/logic/components/SiteFooter.js +476 -0
  101. package/app/logic/components/SiteHeader.js +266 -0
  102. package/app/logic/components/WhatsAppButton.js +75 -0
  103. package/app/logic/layouts/default.js +66 -0
  104. package/app/logic/sections/AnnouncementBarSection.js +6 -0
  105. package/app/logic/sections/BlockCarousel.js +103 -0
  106. package/app/logic/sections/ClassificationListSection.js +40 -0
  107. package/app/logic/sections/ContactFormSection.js +294 -0
  108. package/app/logic/sections/ContactInfoSection.js +419 -0
  109. package/app/logic/sections/ContentNode.js +189 -0
  110. package/app/logic/sections/CountdownSection.js +254 -0
  111. package/app/logic/sections/CustomHtmlSection.js +51 -0
  112. package/app/logic/sections/DividerSection.js +31 -0
  113. package/app/logic/sections/FaqSection.js +53 -0
  114. package/app/logic/sections/FeatureListSection.js +6 -0
  115. package/app/logic/sections/FeaturedProductSection.js +40 -0
  116. package/app/logic/sections/FooterSection.js +16 -0
  117. package/app/logic/sections/FooterUtilitiesSection.js +24 -0
  118. package/app/logic/sections/GallerySection.js +232 -0
  119. package/app/logic/sections/HeaderSection.js +24 -0
  120. package/app/logic/sections/HeroSection.js +24 -0
  121. package/app/logic/sections/LogoListSection.js +6 -0
  122. package/app/logic/sections/MainClassificationSection.js +473 -0
  123. package/app/logic/sections/MainProductSection.js +211 -0
  124. package/app/logic/sections/MainSearchSection.js +6 -0
  125. package/app/logic/sections/MapSection.js +92 -0
  126. package/app/logic/sections/MediaContentSection.js +38 -0
  127. package/app/logic/sections/MediaWithTextSection.js +238 -0
  128. package/app/logic/sections/NewsletterSection.js +126 -0
  129. package/app/logic/sections/ProductHighlightSection.js +201 -0
  130. package/app/logic/sections/ProductListSection.js +45 -0
  131. package/app/logic/sections/ProductRecommendationsSection.js +113 -0
  132. package/app/logic/sections/RichTextSection.js +6 -0
  133. package/app/logic/sections/ScrollingTextSection.js +69 -0
  134. package/app/logic/sections/SearchHeaderSection.js +22 -0
  135. package/app/logic/sections/SlideshowSection.js +118 -0
  136. package/app/logic/sections/SpacerSection.js +37 -0
  137. package/app/logic/sections/SplitShowcaseSection.js +6 -0
  138. package/app/logic/sections/TestimonialListSection.js +6 -0
  139. package/app/logic/sections/VideoSection.js +235 -0
  140. package/app/pages/[category]/[product].vue +196 -0
  141. package/app/pages/__builder-preview.vue +38 -0
  142. package/app/pages/categories/[slug].vue +208 -0
  143. package/app/pages/checkout.vue +1972 -0
  144. package/app/pages/forgot-password.vue +115 -0
  145. package/app/pages/index.vue +64 -0
  146. package/app/pages/login.vue +585 -0
  147. package/app/pages/order-detail.vue +442 -0
  148. package/app/pages/pages/[handle].vue +58 -0
  149. package/app/pages/privacy-policy.vue +46 -0
  150. package/app/pages/resetpassword.vue +138 -0
  151. package/app/pages/resetverificationcode.vue +163 -0
  152. package/app/pages/result.vue +203 -0
  153. package/app/pages/return-policy.vue +50 -0
  154. package/app/pages/search.vue +100 -0
  155. package/app/pages/shipping-policy.vue +46 -0
  156. package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
  157. package/app/pages/user-profile/components/Whishlist.vue +137 -0
  158. package/app/pages/user-profile/components/left-bar.vue +233 -0
  159. package/app/pages/user-profile/components/orders.vue +162 -0
  160. package/app/pages/user-profile/components/profile-details.vue +566 -0
  161. package/app/pages/user-profile/components/settings.vue +194 -0
  162. package/app/pages/user-profile/index.vue +252 -0
  163. package/app/pages/verificationcode.vue +188 -0
  164. package/app/plugins/analytics.client.js +99 -0
  165. package/app/plugins/geo.js +24 -0
  166. package/app/plugins/reveal.client.js +53 -0
  167. package/app/plugins/toast.js +10 -0
  168. package/app/plugins/vue-query.js +5 -0
  169. package/app/services/ApiServices.js +74 -0
  170. package/app/services/AuthBus.js +162 -0
  171. package/app/services/Authentication/AuthenticationService.js +133 -0
  172. package/app/services/Authentication/TokenService.js +62 -0
  173. package/app/services/CartServices.js +91 -0
  174. package/app/services/CartToken.js +26 -0
  175. package/app/services/CategoryServices.js +54 -0
  176. package/app/services/CheckoutServices.js +113 -0
  177. package/app/services/HomeServices.js +133 -0
  178. package/app/services/NewsletterServices.js +29 -0
  179. package/app/services/PaymentServices.js +71 -0
  180. package/app/services/PoliciesServices.js +16 -0
  181. package/app/services/ProductServices.js +79 -0
  182. package/app/services/SearchServices.js +18 -0
  183. package/app/services/UserServices.js +105 -0
  184. package/app/services/UtilsServices.js +46 -0
  185. package/app/services/WishlistServices.js +51 -0
  186. package/app/services/storefront.service.js +57 -0
  187. package/app/stores/userStore.js +13 -0
  188. package/app/theme/categoryTree.js +68 -0
  189. package/app/theme/controls.js +88 -0
  190. package/app/theme/registry.js +56 -0
  191. package/app/utils/analytics.js +864 -0
  192. package/app/utils/device.js +50 -0
  193. package/app/utils/eventBus.js +24 -0
  194. package/app/utils/icon-presets.js +24 -0
  195. package/app/utils/productOffers.js +118 -0
  196. package/app/utils/requestCache.js +36 -0
  197. package/app/utils/scrollToError.js +59 -0
  198. package/app/utils/toPlainError.js +30 -0
  199. package/bin/osimart-theme.mjs +282 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +44 -4
  203. package/public/css/input.css +29 -0
  204. package/public/css/output.css +2716 -0
  205. package/public/favicon-32x32.png +0 -0
  206. package/public/robots.txt +2 -0
@@ -0,0 +1,50 @@
1
+ const STORAGE_KEY = "osimart_device_id";
2
+
3
+ export function getDeviceId() {
4
+ try {
5
+ let deviceId = localStorage.getItem(STORAGE_KEY);
6
+ if (!deviceId) {
7
+ deviceId = crypto.randomUUID();
8
+ localStorage.setItem(STORAGE_KEY, deviceId);
9
+ }
10
+ return deviceId;
11
+ } catch {
12
+ return crypto.randomUUID();
13
+ }
14
+ }
15
+
16
+ export function getDeviceName() {
17
+ if (typeof navigator === "undefined") return "Unknown Device";
18
+ const uaData = navigator.userAgentData;
19
+ const ua = navigator.userAgent;
20
+
21
+ let browser = "Unknown Browser";
22
+ if (uaData?.brands?.length) {
23
+ const brand = uaData.brands.find(
24
+ (b) => !/Not.?A.?Brand|Chromium/i.test(b.brand)
25
+ );
26
+ if (brand) browser = brand.brand.replace(/^Google |^Microsoft /, "");
27
+ }
28
+ if (browser === "Unknown Browser") {
29
+ if (/EdgiOS|Edg\//.test(ua)) browser = "Edge";
30
+ else if (/CriOS|Chrome\//.test(ua)) browser = "Chrome";
31
+ else if (/FxiOS|Firefox\//.test(ua)) browser = "Firefox";
32
+ else if (/Safari\//.test(ua)) browser = "Safari";
33
+ }
34
+
35
+ let os = uaData?.platform || "Unknown OS";
36
+ if (os === "Unknown OS") {
37
+ if (/Windows/.test(ua)) os = "Windows";
38
+ else if (/Android/.test(ua)) os = "Android";
39
+ else if (/iPhone|iPad|iPod/.test(ua)) os = "iOS";
40
+ else if (/Mac OS X/.test(ua)) os = "macOS";
41
+ else if (/Linux/.test(ua)) os = "Linux";
42
+ }
43
+ if (os === "macOS" && navigator.maxTouchPoints > 1) os = "iPadOS";
44
+
45
+ return `${browser} on ${os}`;
46
+ }
47
+
48
+ export function getDeviceInfo() {
49
+ return { device_id: getDeviceId(), device_name: getDeviceName() };
50
+ }
@@ -0,0 +1,24 @@
1
+ import { reactive } from "vue";
2
+
3
+ export const eventBus = reactive({
4
+ events: {},
5
+
6
+ on(event, callback) {
7
+ if (!this.events[event]) {
8
+ this.events[event] = [];
9
+ }
10
+ this.events[event].push(callback);
11
+ },
12
+
13
+ off(event, callback) {
14
+ if (this.events[event]) {
15
+ this.events[event] = this.events[event].filter(cb => cb !== callback);
16
+ }
17
+ },
18
+
19
+ emit(event, data) {
20
+ if (this.events[event]) {
21
+ this.events[event].forEach(callback => callback(data));
22
+ }
23
+ },
24
+ });
@@ -0,0 +1,24 @@
1
+ export const ICON_PRESETS = [
2
+ { name: "Truck", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h11v9H3zM14 9h4l3 3v3h-7z"/><circle cx="7" cy="18" r="1.6"/><circle cx="17.5" cy="18" r="1.6"/></svg>` },
3
+ { name: "Shield", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l7 3v5c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6z"/><path d="M9 12l2 2 4-4"/></svg>` },
4
+ { name: "Return", svg: `<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 48 48">
5
+
6
+ <path d="M0 0h48v48H0z" fill="none"/>
7
+ <g id="Shopicon">
8
+ <path d="M10,22v2c0,7.72,6.28,14,14,14s14-6.28,14-14s-6.28-14-14-14h-4V4l-8,8l8,8v-6h4c5.514,0,10,4.486,10,10s-4.486,10-10,10 s-10-4.486-10-10v-2H10z"/>
9
+ </g>
10
+ </svg>` },
11
+ { name: "Headset", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 13v-1a8 8 0 0 1 16 0v1"/><rect x="3" y="13" width="4" height="6" rx="1"/><rect x="17" y="13" width="4" height="6" rx="1"/><path d="M20 19a4 4 0 0 1-4 3h-2"/></svg>` },
12
+ { name: "Gift", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="1"/><path d="M4 14h16M12 10v10"/><path d="M12 10S9 4 6.5 5.5 9 10 12 10zM12 10s3-6 5.5-4.5S15 10 12 10z"/></svg>` },
13
+ { name: "Tag", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h7l9 9-7 7-9-9z"/><circle cx="8" cy="8" r="1.3"/></svg>` },
14
+ { name: "Credit card", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M3 10h18M7 15h4"/></svg>` },
15
+ { name: "Leaf", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19c0-8 5-14 14-14 0 9-5 14-14 14z"/><path d="M5 19c3-5 6-8 10-10"/></svg>` },
16
+ { name: "Star", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l2.6 5.6 6 .8-4.4 4.2 1.1 6-5.3-3-5.3 3 1.1-6L3.4 9.4l6-.8z"/></svg>` },
17
+ { name: "Clock", svg: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>` },
18
+ { name: "Globe", svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
19
+ <path d="M3 12H21M3 12C3 16.9706 7.02944 21 12 21M3 12C3 7.02944 7.02944 3 12 3M21 12C21 16.9706 16.9706 21 12 21M21 12C21 7.02944 16.9706 3 12 3M12 21C4.75561 13.08 8.98151 5.7 12 3M12 21C19.2444 13.08 15.0185 5.7 12 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
20
+ </svg>` },
21
+ { name: "Heart", svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none">
22
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M12 6.00019C10.2006 3.90317 7.19377 3.2551 4.93923 5.17534C2.68468 7.09558 2.36727 10.3061 4.13778 12.5772C5.60984 14.4654 10.0648 18.4479 11.5249 19.7369C11.6882 19.8811 11.7699 19.9532 11.8652 19.9815C11.9483 20.0062 12.0393 20.0062 12.1225 19.9815C12.2178 19.9532 12.2994 19.8811 12.4628 19.7369C13.9229 18.4479 18.3778 14.4654 19.8499 12.5772C21.6204 10.3061 21.3417 7.07538 19.0484 5.17534C16.7551 3.2753 13.7994 3.90317 12 6.00019Z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
23
+ </svg>` },
24
+ ];
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Product sales, applied the way Shopify applies discounts — shared by the
3
+ * product cards (block-built card via useProductItems, and ProductCard).
4
+ * The product page applies the same rules in useProductDetail.
5
+ *
6
+ * - A 1-item sale (required_quantity 1) is a markdown on every item: the
7
+ * unit price itself drops.
8
+ * - A quantity tier (required_quantity 2+) comes off the total once the
9
+ * quantity reaches it, once per order — so it never changes the unit price
10
+ * and shows as its own box ("Buy 12, save $4 on the total").
11
+ * - Discounts don't combine; the biggest saving wins.
12
+ *
13
+ * The API's discounted_price_range treats every tier as a per-item price, so
14
+ * it isn't used when the variants carry their sales.
15
+ */
16
+
17
+ const round2 = (n) => Math.round(n * 100) / 100;
18
+ const requiredOf = (v) => Math.max(1, Number(Array.isArray(v) ? v[0] : v) || 1);
19
+
20
+ /** Sales running now with a real discount. */
21
+ export function activeSales(variant, now = Date.now()) {
22
+ return (variant?.sales ?? []).filter(
23
+ (sale) =>
24
+ Number(sale.discount_value) > 0 &&
25
+ (!sale.start_date || Date.parse(sale.start_date) <= now) &&
26
+ (!sale.end_date || Date.parse(sale.end_date) > now)
27
+ );
28
+ }
29
+
30
+ /** A variant's price for one item: its price less the best 1-item markdown. */
31
+ export function unitPrice(variant) {
32
+ const price = Number(variant?.price);
33
+ if (!(price > 0)) return 0;
34
+ let best = 0;
35
+ for (const sale of activeSales(variant)) {
36
+ if (requiredOf(sale.required_quantity) > 1) continue;
37
+ const value = Number(sale.discount_value);
38
+ const off = sale.sale_type === "Percentage" ? price * Math.min(value, 100) / 100 : Math.min(value, price);
39
+ best = Math.max(best, off);
40
+ }
41
+ const d = variant.discount;
42
+ if (d?.discounted_price != null && requiredOf(d.required_quantity) <= 1) {
43
+ best = Math.max(best, price - Number(d.discounted_price));
44
+ }
45
+ return round2(price - Math.max(0, best));
46
+ }
47
+
48
+ /**
49
+ * A variant's price for one item and the struck-through price before it:
50
+ * { now, was, sale }. `was` is what gets struck through: the variant's
51
+ * compare_at_price when it is above the price, else the pre-sale price.
52
+ * `sale` is true only for a real sale — compare_at_price is display only and
53
+ * never makes a sale (no badge, no sale colour).
54
+ */
55
+ export function variantPrices(variant) {
56
+ const price = Number(variant?.price) || 0;
57
+ const now = unitPrice(variant) || price;
58
+ const sale = now < price;
59
+ const compare = Number(variant?.compare_at_price) || 0;
60
+ const was = compare > now ? compare : sale ? price : null;
61
+ return { now, was, sale };
62
+ }
63
+
64
+ /** A variant's quantity tiers (2+ items): { id, required, pct, value } */
65
+ export function tiersOf(variant) {
66
+ const price = Number(variant?.price);
67
+ if (!(price > 0)) return [];
68
+ const tiers = activeSales(variant)
69
+ .filter((sale) => requiredOf(sale.required_quantity) > 1)
70
+ .map((sale) => ({
71
+ id: sale.id,
72
+ required: requiredOf(sale.required_quantity),
73
+ pct: sale.sale_type === "Percentage",
74
+ value: Number(sale.discount_value),
75
+ }));
76
+ const d = variant.discount;
77
+ if (d?.discounted_price != null && requiredOf(d.required_quantity) > 1 && Number(d.discounted_price) < price) {
78
+ tiers.push({ id: `discount-${variant.id}`, required: requiredOf(d.required_quantity), pct: false, value: round2(price - Number(d.discounted_price)) });
79
+ }
80
+ return tiers;
81
+ }
82
+
83
+ /**
84
+ * "Buy 12, save $4 on the total" / "Buy 3, save 10% on the total";
85
+ * short: "Buy 12, save $4" (a badge on a card image).
86
+ */
87
+ export function tierText(tier, formatMoney, { short = false } = {}) {
88
+ const amount = tier.pct ? `${tier.value}%` : formatMoney(tier.value);
89
+ return `Buy ${tier.required}, save ${amount}${short ? "" : " on the total"}`;
90
+ }
91
+
92
+ /**
93
+ * A card's price at one item, and its tier boxes:
94
+ * { now: {min, max}, was: {min, max} | null, tiers: [tier] }.
95
+ * Null when the payload doesn't carry the variants' sales (older rows) —
96
+ * the caller keeps its price_range / discounted_price_range then.
97
+ */
98
+ export function cardOffers(product) {
99
+ const variants = (product?.product_variants ?? []).filter((v) => Number(v?.price) > 0);
100
+ if (!variants.length || !variants.some((v) => "sales" in v || "discount" in v)) return null;
101
+
102
+ const pairs = variants.map((v) => variantPrices(v));
103
+ const units = pairs.map((p) => p.now);
104
+ const originals = pairs.map((p) => p.was ?? p.now);
105
+ const now = { min: Math.min(...units), max: Math.max(...units) };
106
+ const base = { min: Math.min(...originals), max: Math.max(...originals) };
107
+ const was = pairs.some((p) => p.was) ? base : null;
108
+ const sale = pairs.some((p) => p.sale);
109
+
110
+ const seen = new Map();
111
+ for (const v of variants) for (const tier of tiersOf(v)) if (!seen.has(tier.id)) seen.set(tier.id, tier);
112
+ const tiers = [...seen.values()].sort((a, b) => a.required - b.required);
113
+
114
+ return { now, was, sale, tiers };
115
+ }
116
+
117
+ /** {min, max} as the "min - max" string the cards' formatters take. */
118
+ export const rangeString = (r) => (r.min === r.max ? String(r.min) : `${r.min} - ${r.max}`);
@@ -0,0 +1,36 @@
1
+
2
+ const store = new Map();
3
+
4
+ export function cachedRequest(key, fetcher, { ttl = 60_000, maxAge = 30 * 60_000 } = {}) {
5
+ if (import.meta.server) return fetcher();
6
+
7
+ const hit = store.get(key);
8
+ const now = Date.now();
9
+ if (hit && "value" in hit && now - hit.at < maxAge) {
10
+ if (!hit.promise && now - hit.at >= ttl) refresh(key, fetcher, hit);
11
+ return Promise.resolve(hit.value);
12
+ }
13
+ if (hit?.promise) return hit.promise;
14
+ return refresh(key, fetcher, null);
15
+ }
16
+
17
+ function refresh(key, fetcher, previous) {
18
+ const promise = Promise.resolve()
19
+ .then(fetcher)
20
+ .then((value) => {
21
+ store.set(key, { value, at: Date.now() });
22
+ return value;
23
+ })
24
+ .catch((error) => {
25
+ if (previous) store.set(key, { value: previous.value, at: previous.at });
26
+ else store.delete(key);
27
+ throw error;
28
+ });
29
+ store.set(key, previous ? { value: previous.value, at: previous.at, promise } : { promise });
30
+ if (previous) promise.catch(() => {});
31
+ return promise;
32
+ }
33
+
34
+ export function clearRequestCache(prefix = "") {
35
+ for (const key of store.keys()) if (key.startsWith(prefix)) store.delete(key);
36
+ }
@@ -0,0 +1,59 @@
1
+ // Scrolls to the first invalid checkout field and focuses it.
2
+ //
3
+ // Fields mark themselves with data-field="<key>" using the same keys
4
+ // validateForm() writes into formErrors, so this stays a pure lookup — no
5
+ // per-field refs, no coupling to the form's markup order beyond DOM order.
6
+ //
7
+ // FIELD_ORDER exists because Object.keys(formErrors) follows insertion order,
8
+ // not visual order: a missing payment method inserted before a missing city
9
+ // would otherwise scroll past the address block.
10
+
11
+ const FIELD_ORDER = [
12
+ "email",
13
+ "email_required",
14
+ "first_name",
15
+ "last_name",
16
+ "mobile_number",
17
+ "mobile_number_required",
18
+ "country",
19
+ "zone",
20
+ "city",
21
+ "address",
22
+ "payment_method_id",
23
+ ];
24
+
25
+ // _required variants share a single DOM node with their base field.
26
+ const domField = (key) => key.replace(/_required$/, "");
27
+
28
+ export function scrollToFirstError(formErrors = {}) {
29
+ if (typeof document === "undefined") return;
30
+
31
+ const keys = Object.keys(formErrors).filter((key) => formErrors[key]);
32
+ if (!keys.length) return;
33
+
34
+ const ordered = [
35
+ ...FIELD_ORDER.filter((key) => keys.includes(key)),
36
+ ...keys.filter((key) => !FIELD_ORDER.includes(key)),
37
+ ];
38
+
39
+ for (const key of ordered) {
40
+ const el = document.querySelector(`[data-field="${domField(key)}"]`);
41
+ if (!el) continue;
42
+
43
+ el.scrollIntoView({ behavior: "smooth", block: "center" });
44
+
45
+ // Focus the input inside the row rather than the row itself, so the caret
46
+ // lands where the user has to type. Skipped for checkbox/radio groups
47
+ // (payment methods) where focusing the first option would look like a pick.
48
+ const input = el.querySelector(
49
+ 'input:not([type="checkbox"]):not([type="radio"]), textarea',
50
+ );
51
+ if (input) {
52
+ // Defer past the smooth scroll — focus() on its own jumps the viewport.
53
+ setTimeout(() => input.focus({ preventScroll: true }), 350);
54
+ }
55
+ return;
56
+ }
57
+ }
58
+
59
+ export default scrollToFirstError;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Turn anything thrown into a plain object safe to put in `useState`.
3
+ *
4
+ * Nuxt serializes every `useState` value into the SSR payload so the client can
5
+ * hydrate from it. That serializer only handles plain data — an `Error`, a class
6
+ * instance, a `Response`, a `Proxy` all fail with:
7
+ *
8
+ * Cannot stringify arbitrary non-POJOs
9
+ *
10
+ * The trap is that this error replaces the one you were trying to report, so a
11
+ * failed API call surfaces as an opaque serialization crash instead of
12
+ * "HTTP 404". Anything stored for display goes through here first.
13
+ *
14
+ * `useAsyncData`'s own `error` does not need this — Nuxt normalises that one
15
+ * itself. This is only for errors you park in state by hand.
16
+ */
17
+ export function toPlainError(e) {
18
+ if (e === null || e === undefined) return null;
19
+
20
+ // Already plain.
21
+ if (typeof e === "string") return { message: e, status: null, data: null };
22
+
23
+ return {
24
+ message: e.message ?? e.detail ?? "Something went wrong",
25
+ // apiService attaches these; $fetch/ofetch uses statusCode.
26
+ status: e.status ?? e.statusCode ?? null,
27
+ // Response bodies from DRF are plain JSON, so they serialize fine.
28
+ data: e.data ?? e.response?._data ?? null,
29
+ };
30
+ }
@@ -0,0 +1,282 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * osimart-theme — the theme repo's link to its store, like the Shopify CLI.
4
+ *
5
+ * osimart-theme themes list the store's themes (id, name, role)
6
+ * osimart-theme pull [--theme live|<id>] [--delete]
7
+ * osimart-theme push [--theme draft|live|<id>] [--force] [--prune] [--dry-run]
8
+ * osimart-theme publish <id> make a theme the live one
9
+ *
10
+ * Files (theme repo):
11
+ * theme/config/settings_data.json theme settings
12
+ * theme/sections/header-group.json header group sections
13
+ * theme/sections/footer-group.json footer group sections
14
+ * theme/templates/<type>[.<suffix>].json index.json, product.json, product.alternate.json, page.contact.json …
15
+ * app/sections/<Name>Section.schema.json schema of a section type the theme adds
16
+ * .osimart/state.json what was last pulled (git-ignored)
17
+ * .osimart/schemas.json every section schema, for reference (git-ignored)
18
+ *
19
+ * Env (read from .env too):
20
+ * OSIMART_THEME_TOKEN Theme access token (dashboard → Theme access)
21
+ * NUXT_PUBLIC_STORE_API_BASE the store's API (already set for `npm run dev`); the CLI calls
22
+ * <its origin>/store/apis/theme-dev/
23
+ *
24
+ * Backend contract: THEME_DEV_API.md in @ositcom/osimart-storefront.
25
+ */
26
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
27
+ import { basename, join, resolve } from "node:path";
28
+
29
+ const ROOT = process.cwd();
30
+ const THEME_DIR = join(ROOT, "theme");
31
+ const TEMPLATES_DIR = join(THEME_DIR, "templates");
32
+ const GROUPS_DIR = join(THEME_DIR, "sections");
33
+ const SETTINGS_FILE = join(THEME_DIR, "config", "settings_data.json");
34
+ const SECTIONS_DIR = join(ROOT, "app", "sections");
35
+ const STATE_DIR = join(ROOT, ".osimart");
36
+ const STATE_FILE = join(STATE_DIR, "state.json");
37
+
38
+ /* ---------------------------------------------------------------- env */
39
+
40
+ function loadDotenv() {
41
+ const file = join(ROOT, ".env");
42
+ if (!existsSync(file)) return;
43
+ for (const line of readFileSync(file, "utf8").split("\n")) {
44
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
45
+ if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
46
+ }
47
+ }
48
+ loadDotenv();
49
+
50
+ function fail(message) {
51
+ console.error(`✖ ${message}`);
52
+ process.exit(1);
53
+ }
54
+
55
+ /**
56
+ * The builder API's theme-dev route, on the same backend the storefront
57
+ * already talks to (NUXT_PUBLIC_STORE_API_BASE). OSIMART_THEME_DEV_URL only
58
+ * overrides it if the routes are mounted somewhere other than /store/apis/theme-dev/.
59
+ */
60
+ const BUILDER_PATH = "/store/apis/theme-dev/";
61
+
62
+ function baseUrl() {
63
+ const override = process.env.OSIMART_THEME_DEV_URL;
64
+ if (override) return override.endsWith("/") ? override : `${override}/`;
65
+ const api = process.env.NUXT_PUBLIC_STORE_API_BASE;
66
+ if (!api) fail("Set NUXT_PUBLIC_STORE_API_BASE in .env (the store's API, e.g. http://test6.192.168.1.12.nip.io:8000/)");
67
+ return new URL(BUILDER_PATH, api).toString();
68
+ }
69
+
70
+ async function request(method, path, body) {
71
+ const token = process.env.OSIMART_THEME_TOKEN;
72
+ if (!token) fail("Set OSIMART_THEME_TOKEN in .env (dashboard → Theme access)");
73
+ const url = new URL(path, baseUrl());
74
+ const res = await fetch(url, {
75
+ method,
76
+ headers: { Authorization: `ThemeToken ${token}`, "Content-Type": "application/json", Accept: "application/json" },
77
+ body: body === undefined ? undefined : JSON.stringify(body),
78
+ });
79
+ const text = await res.text();
80
+ let data = null;
81
+ try { data = text ? JSON.parse(text) : null; } catch { data = { detail: `not a theme-dev endpoint: ${url}` }; }
82
+ if (!res.ok) {
83
+ const err = new Error(data?.detail || data?.message || `HTTP ${res.status}`);
84
+ err.status = res.status;
85
+ err.data = data;
86
+ throw err;
87
+ }
88
+ return data;
89
+ }
90
+
91
+ /* ---------------------------------------------------------------- files */
92
+
93
+ const readJson = (file) => {
94
+ try { return JSON.parse(readFileSync(file, "utf8")); }
95
+ catch (e) { fail(`${file.replace(ROOT + "/", "")}: ${e.message}`); }
96
+ };
97
+ const writeJson = (file, data) => {
98
+ mkdirSync(resolve(file, ".."), { recursive: true });
99
+ writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
100
+ };
101
+ const listJson = (dir) => (existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".json")) : []);
102
+
103
+ /** {type: "PRODUCT", suffix: "alternate"} <-> product.alternate.json */
104
+ const templateFile = ({ type, suffix }) => `${String(type).toLowerCase()}${suffix ? `.${suffix}` : ""}.json`;
105
+ function templateKey(file) {
106
+ const name = basename(file, ".json");
107
+ const dot = name.indexOf(".");
108
+ return dot === -1
109
+ ? { type: name.toUpperCase(), suffix: "" }
110
+ : { type: name.slice(0, dot).toUpperCase(), suffix: name.slice(dot + 1) };
111
+ }
112
+
113
+ /** header <-> header-group.json */
114
+ const groupFile = (name) => `${name}-group.json`;
115
+ const groupName = (file) => basename(file, ".json").replace(/-group$/, "");
116
+
117
+ /** LookbookGridSection.schema.json -> LOOKBOOK_GRID */
118
+ const typeFromSchemaFile = (file) =>
119
+ basename(file, ".schema.json").replace(/Section$/, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
120
+
121
+ function readLocalTheme() {
122
+ return {
123
+ settings: existsSync(SETTINGS_FILE) ? readJson(SETTINGS_FILE) : undefined,
124
+ groups: Object.fromEntries(listJson(GROUPS_DIR).map((f) => [groupName(f), readJson(join(GROUPS_DIR, f))])),
125
+ templates: listJson(TEMPLATES_DIR).map((f) => ({ ...templateKey(f), data: readJson(join(TEMPLATES_DIR, f)) })),
126
+ };
127
+ }
128
+
129
+ function readLocalSchemas() {
130
+ if (!existsSync(SECTIONS_DIR)) return [];
131
+ return readdirSync(SECTIONS_DIR)
132
+ .filter((f) => f.endsWith(".schema.json"))
133
+ .map((f) => {
134
+ const json = readJson(join(SECTIONS_DIR, f));
135
+ const type = typeFromSchemaFile(f);
136
+ if (!existsSync(join(SECTIONS_DIR, f.replace(/\.schema\.json$/, ".vue")))) {
137
+ fail(`${f}: no matching ${f.replace(/\.schema\.json$/, ".vue")}`);
138
+ }
139
+ return {
140
+ type,
141
+ name: json.name || type,
142
+ ...(json.group ? { group: json.group } : {}),
143
+ allowed_templates: json.templates || json.allowed_templates || [],
144
+ schema: { settings: json.settings || [], blocks: json.blocks || [], ...(json.max_blocks ? { max_blocks: json.max_blocks } : {}) },
145
+ };
146
+ });
147
+ }
148
+
149
+ const state = () => (existsSync(STATE_FILE) ? readJson(STATE_FILE) : {});
150
+ const saveState = (patch) => writeJson(STATE_FILE, { ...state(), ...patch });
151
+
152
+ function flag(args, name, fallback) {
153
+ const i = args.indexOf(name);
154
+ return i === -1 ? fallback : args[i + 1];
155
+ }
156
+
157
+ /* ---------------------------------------------------------------- commands */
158
+
159
+ async function themes() {
160
+ const list = await request("GET", "themes/");
161
+ const pulled = state().theme_id;
162
+ for (const t of list) {
163
+ console.log(`${String(t.id).padEnd(38)} ${String(t.role).padEnd(9)} ${t.name}${t.id === pulled ? " ← pulled" : ""}`);
164
+ }
165
+ }
166
+
167
+ async function pull(args) {
168
+ const theme = flag(args, "--theme", "live");
169
+ const data = await request("GET", `theme/?theme=${encodeURIComponent(theme)}`);
170
+
171
+ if (data.settings) writeJson(SETTINGS_FILE, data.settings);
172
+
173
+ const groups = data.groups || {};
174
+ for (const [name, value] of Object.entries(groups)) writeJson(join(GROUPS_DIR, groupFile(name)), value);
175
+
176
+ const wanted = new Set();
177
+ for (const t of data.templates || []) {
178
+ const file = templateFile(t);
179
+ wanted.add(file);
180
+ writeJson(join(TEMPLATES_DIR, file), t.data);
181
+ }
182
+
183
+ // Local templates the theme no longer has.
184
+ const stale = listJson(TEMPLATES_DIR).filter((f) => !wanted.has(f));
185
+ if (stale.length) {
186
+ if (args.includes("--delete")) stale.forEach((f) => rmSync(join(TEMPLATES_DIR, f)));
187
+ else console.log(` not on the store (kept; --delete removes): ${stale.join(", ")}`);
188
+ }
189
+
190
+ try {
191
+ writeJson(join(STATE_DIR, "schemas.json"), await request("GET", "schemas/"));
192
+ } catch (e) {
193
+ console.log(` (schemas not saved: ${e.message})`);
194
+ }
195
+
196
+ saveState({
197
+ store_id: data.theme.store?.id, store_name: data.theme.store?.name,
198
+ theme_id: data.theme.id, theme_name: data.theme.name, role: data.theme.role,
199
+ version: data.theme.version, pulled_at: new Date().toISOString(),
200
+ });
201
+ console.log(`✔ pulled "${data.theme.name}" (${data.theme.role}) from ${data.theme.store?.name ?? "the store"} — ${data.templates?.length ?? 0} templates, ${Object.keys(groups).length} groups`);
202
+ }
203
+
204
+ async function push(args) {
205
+ const dryRun = args.includes("--dry-run");
206
+ const force = args.includes("--force");
207
+ const prune = args.includes("--prune");
208
+ const theme = flag(args, "--theme", "draft");
209
+
210
+ // 1. Section types the theme adds (their schemas), so the editor can place them.
211
+ const sections = readLocalSchemas();
212
+ if (sections.length || prune) {
213
+ let report;
214
+ try {
215
+ report = await request("POST", "sections/sync/", {
216
+ sections, prune, dry_run: dryRun, expected_store: force ? null : state().store_id ?? null,
217
+ });
218
+ } catch (e) {
219
+ if (e.status === 400 && e.data) fail(`Section schemas rejected:\n${JSON.stringify(e.data, null, 2)}`);
220
+ throw e;
221
+ }
222
+ console.log(`${dryRun ? "◌" : "✔"} section schemas: ${sections.length}`);
223
+ for (const key of ["created", "updated", "unchanged", "deleted", "kept_in_use"]) {
224
+ if (report?.[key]?.length) console.log(` ${key.padEnd(11)} ${report[key].join(", ")}`);
225
+ }
226
+ }
227
+
228
+ // 2. Templates, groups, settings.
229
+ const local = readLocalTheme();
230
+ if (!local.templates.length && !Object.keys(local.groups).length && local.settings === undefined) {
231
+ console.log(" nothing in theme/ to push (run pull first)");
232
+ return;
233
+ }
234
+ const { version, theme_id, role, store_id } = state();
235
+ const sameTheme = theme === theme_id || theme === String(role || "").toLowerCase();
236
+ try {
237
+ const res = await request("PUT", "theme/", {
238
+ theme,
239
+ // The merchant may have edited the theme in the dashboard since the pull.
240
+ base_version: force || !sameTheme ? null : version ?? null,
241
+ // Files pulled from another store (token swapped)? The backend refuses.
242
+ expected_store: force ? null : store_id ?? null,
243
+ dry_run: dryRun,
244
+ ...local,
245
+ });
246
+ console.log(`${dryRun ? "◌ dry run" : "✔ pushed"} to "${res.theme.name}" (${res.theme.role}) on ${res.theme.store?.name ?? "the store"} — ${local.templates.length} templates`);
247
+ for (const key of ["created", "updated", "unchanged"]) {
248
+ if (res[key]?.length) console.log(` ${key.padEnd(9)} ${res[key].join(", ")}`);
249
+ }
250
+ if (res.preview_url) console.log(` preview: ${res.preview_url}`);
251
+ if (!dryRun && res.theme.id === theme_id) saveState({ version: res.theme.version });
252
+ } catch (e) {
253
+ if (e.status === 409) {
254
+ fail("The theme changed on the store since your last pull (edited in the dashboard).\n Run `osimart-theme pull`, merge with git, then push — or push --force to overwrite.");
255
+ }
256
+ if (e.status === 400 && e.data) fail(`Rejected:\n${JSON.stringify(e.data, null, 2)}`);
257
+ throw e;
258
+ }
259
+ }
260
+
261
+ async function publish(args) {
262
+ const id = args[0];
263
+ if (!id) fail("Usage: osimart-theme publish <theme-id> (see: osimart-theme themes)");
264
+ const res = await request("POST", `themes/${encodeURIComponent(id)}/publish/`);
265
+ console.log(`✔ "${res.name}" is now live`);
266
+ }
267
+
268
+ /* ---------------------------------------------------------------- main */
269
+
270
+ const [command, ...args] = process.argv.slice(2);
271
+ const commands = { themes, pull, push, publish };
272
+
273
+ if (!commands[command]) {
274
+ console.log(`Usage:
275
+ osimart-theme themes
276
+ osimart-theme pull [--theme live|<id>] [--delete]
277
+ osimart-theme push [--theme draft|live|<id>] [--force] [--prune] [--dry-run]
278
+ osimart-theme publish <id>`);
279
+ process.exit(command ? 1 : 0);
280
+ }
281
+
282
+ commands[command](args).catch((e) => fail(e.status ? `${e.status} ${e.message}` : e.message));