@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,118 @@
1
+ import { A11y, Autoplay, EffectFade, Keyboard } from "swiper/modules";
2
+
3
+ export default function useLogic({ props }) {
4
+ const { s, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
5
+ const { tree, editing, u } = useContentTree(() => props.section, {
6
+ rootDefaults: { DIRECTION: "HORIZONTAL" },
7
+ index: () => props.index,
8
+ });
9
+ const { num, px, upper } = u;
10
+
11
+ const slides = computed(() => tree.value.children.filter((n) => n.slide));
12
+ const count = computed(() => slides.value.length);
13
+
14
+ const swiper = ref(null);
15
+ const current = ref(0);
16
+ function onSwiper(instance) {
17
+ swiper.value = instance;
18
+ }
19
+ function onSlideChange(instance) {
20
+ current.value = trueLoop.value ? instance.realIndex : instance.activeIndex;
21
+ }
22
+
23
+ const fade = computed(() => upper(s.value.TRANSITION, "SLIDE") === "FADE");
24
+ const loopSetting = computed(() => s.value.LOOP !== false);
25
+ // Builder editing: rewind (backward transition, no clones — clones would
26
+ // duplicate data-osi-block and confuse the overlay). Live storefront: real
27
+ // loop (forward-continuous, no backward jump).
28
+ const rewind = computed(() => loopSetting.value && editing.value);
29
+ const trueLoop = computed(() => loopSetting.value && !editing.value && count.value > 1);
30
+ const adapt = computed(() => upper(s.value.HEIGHT, "LARGE") === "ADAPT");
31
+
32
+ const go = (i) => swiper.value?.slideToLoop ? swiper.value.slideToLoop(i) : swiper.value?.slideTo(i);
33
+ const next = () => swiper.value?.slideNext();
34
+ const prev = () => swiper.value?.slidePrev();
35
+ const canPrev = computed(() => rewind.value || trueLoop.value || current.value > 0);
36
+ const canNext = computed(() => rewind.value || trueLoop.value || current.value < count.value - 1);
37
+
38
+ /* ---------------------------------------------------------------- autoplay */
39
+
40
+ const reducedMotion = ref(false);
41
+ onMounted(() => {
42
+ reducedMotion.value = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
43
+ });
44
+
45
+ const autoplay = computed(() =>
46
+ s.value.AUTOPLAY && !editing.value && !reducedMotion.value && count.value > 1
47
+ ? {
48
+ delay: Math.max(1500, num(s.value.INTERVAL, 5000)),
49
+ disableOnInteraction: false,
50
+ pauseOnMouseEnter: s.value.PAUSE_ON_HOVER !== false,
51
+ }
52
+ : false
53
+ );
54
+
55
+ // Swiper reads `autoplay` once; follow later changes (a settings edit, the
56
+ // builder connecting, reduced motion detected after mount).
57
+ watch(autoplay, (value) => {
58
+ const instance = swiper.value;
59
+ if (!instance?.autoplay) return;
60
+ if (value) {
61
+ Object.assign(instance.params.autoplay, value);
62
+ instance.autoplay.start();
63
+ } else {
64
+ instance.autoplay.stop();
65
+ }
66
+ });
67
+
68
+ /* ------------------------------------------------ builder: show the edited */
69
+
70
+ const { selectedKey } = usePreviewOverlay();
71
+ watch(
72
+ () => selectedKey.value,
73
+ (key) => {
74
+ if (!key) return;
75
+ const byKey = new Map((props.section.blocks ?? []).map((b) => [b.key, b]));
76
+ let block = byKey.get(key);
77
+ for (let guard = 0; block && block.type !== "SLIDE" && guard < 20; guard++) {
78
+ block = byKey.get(block.parent_key);
79
+ }
80
+ if (!block) return;
81
+ const at = slides.value.findIndex((n) => n.key === (block.id ?? block.key));
82
+ if (at >= 0 && at !== current.value) go(at);
83
+ }
84
+ );
85
+
86
+ // Slides added / removed / reordered in the builder: let Swiper re-measure.
87
+ watch(
88
+ () => slides.value.map((n) => n.key).join(),
89
+ () => nextTick(() => swiper.value?.update())
90
+ );
91
+
92
+ /* ------------------------------------------------------------------ sizing */
93
+
94
+ const HEIGHTS = { SMALL: "320px", MEDIUM: "480px", LARGE: "640px", FULL: "100svh", ADAPT: "auto" };
95
+
96
+ const vars = computed(() => {
97
+ const v = s.value;
98
+ const mode = upper(v.HEIGHT, "LARGE");
99
+ const height = mode === "CUSTOM" ? px(num(v.HEIGHT_CUSTOM, 560)) : HEIGHTS[mode] ?? HEIGHTS.LARGE;
100
+ return {
101
+ "--ss-h": height,
102
+ "--ss-h-m": adapt.value ? "auto" : px(v.HEIGHT_MOBILE) ?? height,
103
+ ...(v.ARROW_BG_COLOR ? { "--ss-arrow-bg": v.ARROW_BG_COLOR } : {}),
104
+ ...(v.ARROW_COLOR ? { "--ss-arrow": v.ARROW_COLOR } : {}),
105
+ ...(v.ARROW_SIZE ? { "--ss-arrow-size": px(v.ARROW_SIZE) } : {}),
106
+ ...(v.DOT_COLOR ? { "--ss-dot": v.DOT_COLOR } : {}),
107
+ ...(v.DOT_ACTIVE_COLOR ? { "--ss-dot-active": v.DOT_ACTIVE_COLOR } : {}),
108
+ };
109
+ });
110
+
111
+ const showArrows = computed(() => s.value.SHOW_ARROWS !== false && count.value > 1);
112
+ const showDots = computed(() => s.value.SHOW_DOTS !== false && count.value > 1);
113
+ const dotStyle = computed(() => (upper(s.value.DOT_STYLE, "DOT") === "BAR" ? "bar" : "dot"));
114
+
115
+ const modules = [A11y, Autoplay, EffectFade, Keyboard];
116
+
117
+ return { s, shellStyle, innerStyle, customClass, tree, editing, u, num, px, upper, slides, count, swiper, current, onSwiper, onSlideChange, fade, loopSetting, rewind, trueLoop, adapt, go, next, prev, canPrev, canNext, reducedMotion, autoplay, selectedKey, HEIGHTS, vars, showArrows, showDots, dotStyle, modules };
118
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * SPACER — vertical breathing room, optionally with a divider line.
3
+ *
4
+ * The only section that intentionally renders nothing but space, so it
5
+ * deliberately skips useSectionShell's padding: a spacer with 48px of default
6
+ * section padding on top of its own height is 156px of gap when the merchant
7
+ * asked for 60.
8
+ */
9
+
10
+
11
+ export default function useLogic({ props }) {
12
+ const { s, scheme, customClass } = useSectionShell(() => props.section);
13
+
14
+ // Numeric settings fields sometimes arrive as strings (or "60px"), and 0
15
+ // is a legitimate value — so don't rely on `||` fallback, which treats
16
+ // both NaN and 0 as "missing".
17
+ function toNum(val, fallback) {
18
+ const n = typeof val === 'string' ? parseFloat(val) : Number(val);
19
+ return Number.isFinite(n) ? n : fallback;
20
+ }
21
+
22
+ // Boolean settings fields sometimes arrive as the strings "true"/"false",
23
+ // which are both truthy in JS — so `!!val` alone is unsafe.
24
+ function toBool(val) {
25
+ if (typeof val === 'string') return val.toLowerCase() === 'true' || val === '1';
26
+ return !!val;
27
+ }
28
+
29
+ const height = computed(() => toNum(s.value.SPACER_HEIGHT, 60));
30
+ const heightMobile = computed(() =>
31
+ toNum(s.value.SPACER_HEIGHT_MOBILE, Math.max(16, Math.round(height.value / 2)))
32
+ );
33
+ const showLine = computed(() => toBool(s.value.SHOW_LINE ?? s.value.SHOW_DIVIDER));
34
+ const lineColor = computed(() => s.value.LINE_COLOR || s.value.DIVIDER_COLOR || '');
35
+
36
+ return { s, scheme, customClass, toNum, toBool, height, heightMobile, showLine, lineColor };
37
+ }
@@ -0,0 +1,6 @@
1
+
2
+ export default function useLogic() {
3
+
4
+
5
+ return { };
6
+ }
@@ -0,0 +1,6 @@
1
+
2
+ export default function useLogic() {
3
+ const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "TOP", GAP: 32 };
4
+
5
+ return { ROOT_DEFAULTS };
6
+ }
@@ -0,0 +1,235 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
4
+ const { mediaUrl } = useMediaUrl();
5
+ const { isMobile } = useLayoutShell();
6
+
7
+ const { tree, editing, u } = useContentTree(() => props.section, {
8
+ rootDefaults: { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "CENTER", GAP: 16 },
9
+ index: () => props.index,
10
+ });
11
+ const { num, px, compact, upper } = u;
12
+
13
+ /* ----------------------------------------------------------------- source */
14
+
15
+ const source = computed(() => {
16
+ const chosen = upper(s.value.VIDEO_SOURCE, "");
17
+ if (chosen === "UPLOAD" || chosen === "LINK") return chosen;
18
+ return s.value.VIDEO_URL ? "LINK" : "UPLOAD";
19
+ });
20
+
21
+ /** { kind: 'file' | 'youtube' | 'vimeo', src | id } or null. */
22
+ const video = computed(() => {
23
+ const v = s.value;
24
+ if (source.value === "UPLOAD") {
25
+ const media = (isMobile.value && v.MOBILE_VIDEO_MEDIA) || v.VIDEO_MEDIA;
26
+ const src = mediaUrl(media ?? null);
27
+ return src ? { kind: "file", src } : null;
28
+ }
29
+ const url = String(v.VIDEO_URL ?? "").trim();
30
+ if (!url) return null;
31
+ const yt = url.match(/(?:youtu\.be\/|watch\?v=|\/embed\/|\/shorts\/|\/live\/)([A-Za-z0-9_-]{11})/);
32
+ if (yt) return { kind: "youtube", id: yt[1] };
33
+ const vm = url.match(/vimeo\.com\/(?:video\/)?(\d+)/);
34
+ if (vm) return { kind: "vimeo", id: vm[1] };
35
+ if (/^https?:\/\//i.test(url)) return { kind: "file", src: url };
36
+ return null;
37
+ });
38
+
39
+ const poster = computed(() => {
40
+ const own = mediaUrl(s.value.POSTER ?? null);
41
+ if (own) return own;
42
+ // YouTube publishes a still for every video; better than a black box.
43
+ return video.value?.kind === "youtube" ? `https://i.ytimg.com/vi/${video.value.id}/hqdefault.jpg` : "";
44
+ });
45
+ const label = computed(() => t(s.value.VIDEO_ALT) || "Video");
46
+
47
+ /* --------------------------------------------------------------- playback */
48
+
49
+ const reducedMotion = ref(false);
50
+ const autoplay = computed(() => !!s.value.AUTOPLAY && !reducedMotion.value);
51
+ const loop = computed(() => s.value.LOOP !== false);
52
+ const controls = computed(() => s.value.SHOW_CONTROLS !== false);
53
+ const startMuted = computed(() => autoplay.value || s.value.MUTED !== false);
54
+
55
+ /** A clicked (non-autoplay) video is playing: hide the cover and content. */
56
+ const playing = ref(false);
57
+ /** Embeds: the iframe exists (autoplay, or after the click). */
58
+ const embedLoaded = ref(false);
59
+
60
+ const embedSrc = computed(() => {
61
+ const v = video.value;
62
+ if (!v || v.kind === "file") return null;
63
+ const auto = autoplay.value || embedLoaded.value;
64
+ const muted = autoplay.value ? 1 : startMuted.value && !playing.value ? 1 : 0;
65
+ if (v.kind === "youtube") {
66
+ const q = new URLSearchParams({
67
+ autoplay: auto ? "1" : "0",
68
+ mute: String(muted),
69
+ controls: controls.value ? "1" : "0",
70
+ loop: loop.value ? "1" : "0",
71
+ playlist: v.id, // YouTube only loops a playlist — of this one video
72
+ playsinline: "1",
73
+ rel: "0",
74
+ modestbranding: "1",
75
+ });
76
+ return `https://www.youtube-nocookie.com/embed/${v.id}?${q}`;
77
+ }
78
+ const q = new URLSearchParams({
79
+ autoplay: auto ? "1" : "0",
80
+ muted: String(muted),
81
+ loop: loop.value ? "1" : "0",
82
+ controls: controls.value ? "1" : "0",
83
+ // Vimeo's chromeless, silent, looping mode — for autoplay without controls.
84
+ background: autoplay.value && !controls.value ? "1" : "0",
85
+ playsinline: "1",
86
+ dnt: "1",
87
+ });
88
+ return `https://player.vimeo.com/video/${v.id}?${q}`;
89
+ });
90
+
91
+ // ---- self-hosted files: load when near the viewport, play/pause with it
92
+ const videoEl = ref(null);
93
+ let observer = null;
94
+
95
+ function attach(el) {
96
+ observer?.disconnect();
97
+ if (!el || typeof window === "undefined" || !("IntersectionObserver" in window)) return;
98
+ observer = new IntersectionObserver(
99
+ ([entry]) => {
100
+ if (entry.isIntersecting) {
101
+ if (!el.getAttribute("src") && el.dataset.src) {
102
+ el.src = el.dataset.src;
103
+ el.load();
104
+ }
105
+ if (autoplay.value) el.play().catch(() => {});
106
+ } else if (autoplay.value) {
107
+ el.pause();
108
+ }
109
+ },
110
+ { rootMargin: "200px 0px", threshold: 0.01 }
111
+ );
112
+ observer.observe(el);
113
+ }
114
+
115
+ watch(videoEl, attach);
116
+ // A new file (edited in the builder) must replace what's loaded.
117
+ watch(() => video.value?.src, () => {
118
+ const el = videoEl.value;
119
+ if (!el) return;
120
+ el.removeAttribute("src");
121
+ playing.value = false;
122
+ attach(el);
123
+ });
124
+
125
+ onMounted(() => {
126
+ reducedMotion.value = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
127
+ if (autoplay.value && video.value && video.value.kind !== "file") embedLoaded.value = true;
128
+ });
129
+ onBeforeUnmount(() => observer?.disconnect());
130
+
131
+ async function play() {
132
+ const v = video.value;
133
+ if (!v) return;
134
+ if (v.kind !== "file") {
135
+ // Embeds: there's no reliable "frames are now rendering" event without
136
+ // the platform's postMessage API, and the iframe shows its own
137
+ // thumbnail while it buffers — so there's no bare gap to bridge here.
138
+ playing.value = true;
139
+ embedLoaded.value = true;
140
+ return;
141
+ }
142
+ const el = videoEl.value;
143
+ if (!el) return;
144
+ if (!el.getAttribute("src")) {
145
+ el.src = el.dataset.src;
146
+ el.load();
147
+ }
148
+ el.muted = startMuted.value;
149
+ el.controls = controls.value;
150
+ try {
151
+ await el.play();
152
+ // Don't flip `playing` here — el.play() resolving just means playback
153
+ // was *requested*, not that a frame is on screen yet. Wait for the
154
+ // native 'playing' event (below) so the cover stays up, unbroken,
155
+ // until the video is actually visible — no gap, no re-flash of poster.
156
+ } catch {
157
+ playing.value = false;
158
+ }
159
+ }
160
+
161
+ function onPause() {
162
+ if (!autoplay.value) playing.value = false;
163
+ }
164
+ function onPlaying() {
165
+ if (!autoplay.value) playing.value = true;
166
+ }
167
+
168
+ const showCover = computed(() => {
169
+ const v = video.value;
170
+ if (!v || autoplay.value) return false;
171
+ return v.kind === "file" ? !playing.value : !embedLoaded.value;
172
+ });
173
+
174
+ /* ------------------------------------------------------------------ sizing */
175
+
176
+ const RATIOS = { WIDE: "16 / 9", CLASSIC: "4 / 3", SQUARE: "1 / 1", PORTRAIT: "4 / 5", VERTICAL: "9 / 16", CINEMA: "21 / 9" };
177
+ const HEIGHTS = { SMALL: "40vh", MEDIUM: "60vh", LARGE: "80vh", FULL: "100svh" };
178
+
179
+ const frameVars = computed(() => {
180
+ const v = s.value;
181
+ const height = upper(v.HEIGHT, "RATIO");
182
+ const ratio = RATIOS[upper(v.RATIO, "WIDE")] ?? RATIOS.WIDE;
183
+ const mobileRatio = RATIOS[upper(v.RATIO_MOBILE, "")];
184
+ const fixed = height === "CUSTOM" ? px(num(v.HEIGHT_CUSTOM, 560)) : HEIGHTS[height];
185
+ const pad = num(v.CONTENT_PADDING, 40);
186
+ return compact({
187
+ "--vs-w": `${num(v.VIDEO_WIDTH, 100)}%`,
188
+ "--vs-w-m": `${num(v.VIDEO_WIDTH_MOBILE, 100)}%`,
189
+ // Desktop: a ratio or a height. Phones: RATIO_MOBILE wins, else the same.
190
+ "--vs-ratio": fixed ? "auto" : ratio,
191
+ "--vs-h": fixed ?? "auto",
192
+ "--vs-ratio-m": mobileRatio ?? (fixed ? "auto" : ratio),
193
+ "--vs-h-m": mobileRatio ? "auto" : fixed ?? "auto",
194
+ "--vs-fit": upper(v.FIT, "COVER") === "CONTAIN" ? "contain" : "cover",
195
+ "--vs-radius": px(v.VIDEO_RADIUS),
196
+ "--vs-border":
197
+ upper(v.BORDER_STYLE, "NONE") === "SOLID"
198
+ ? `${num(v.BORDER_WIDTH, 1)}px solid ${v.BORDER_COLOR || "currentColor"}`
199
+ : undefined,
200
+ "--vs-pad": `${pad}px`,
201
+ "--vs-pad-m": `${num(v.CONTENT_PADDING_MOBILE, pad)}px`,
202
+ "--vs-text": v.CONTENT_TEXT_COLOR,
203
+ });
204
+ });
205
+
206
+ const overlay = computed(() => {
207
+ const opacity = num(s.value.OVERLAY_OPACITY, 0);
208
+ return opacity > 0 ? { background: s.value.OVERLAY_COLOR || "#000", opacity: opacity / 100 } : null;
209
+ });
210
+
211
+ /* ----------------------------------------------------------------- content */
212
+
213
+ const POSITIONS = ["OVERLAY", "BELOW", "ABOVE"];
214
+ const position = computed(() => {
215
+ const p = upper(s.value.CONTENT_POSITION, "OVERLAY");
216
+ return POSITIONS.includes(p) ? p : "OVERLAY";
217
+ });
218
+ const positionMobile = computed(() => {
219
+ const p = upper(s.value.CONTENT_POSITION_MOBILE, "");
220
+ return POSITIONS.includes(p) ? p : position.value;
221
+ });
222
+ const hasContent = computed(() => tree.value.children.length > 0 || editing.value);
223
+
224
+ const frameClasses = computed(() => [
225
+ `pos-${position.value.toLowerCase()}`,
226
+ `mpos-${positionMobile.value.toLowerCase()}`,
227
+ {
228
+ "is-playing": playing.value && !autoplay.value,
229
+ "fit-contain": upper(s.value.FIT, "COVER") === "CONTAIN",
230
+ "has-content": hasContent.value,
231
+ },
232
+ ]);
233
+
234
+ return { s, t, shellStyle, innerStyle, customClass, mediaUrl, isMobile, tree, editing, u, num, px, compact, upper, source, video, poster, label, reducedMotion, autoplay, loop, controls, startMuted, playing, embedLoaded, embedSrc, videoEl, observer, attach, play, onPause, onPlaying, showCover, RATIOS, HEIGHTS, frameVars, overlay, POSITIONS, position, positionMobile, hasContent, frameClasses };
235
+ }
@@ -0,0 +1,196 @@
1
+ <script setup>
2
+ import { useRenderContext } from "#osi/composables/useRenderContext";
3
+ import { useProductResource } from "#osi/composables/useProductDetail";
4
+
5
+ /**
6
+ * Product detail — `/<category-slug>/<product-slug>`.
7
+ *
8
+ * Same three lines every theme-driven route has: ask the backend which template
9
+ * serves this path, publish the payload, hand the sections to SectionRenderer.
10
+ * The layout of a product page lives in MAIN_PRODUCT, not here.
11
+ *
12
+ * ⚠ Backend prerequisite — `resolve_path()` has to match a two-segment path to
13
+ * the PRODUCT template, and match it LAST so it can't shadow the prefixes:
14
+ *
15
+ * /categories/<slug> CLASSIFICATION
16
+ * /collections/<slug> CLASSIFICATION
17
+ * /pages/<handle> PAGE
18
+ * /products/<slug> PRODUCT (keep — uncategorised products use it)
19
+ * /<category>/<slug> PRODUCT ← last
20
+ *
21
+ * This file serves `/products/<slug>` too: it's just this route with
22
+ * `category === "products"`, and useProductResource only reads the second
23
+ * segment.
24
+ *
25
+ * The product itself is awaited here rather than in the section so it lands in
26
+ * the SSR payload — the section re-reads it from cache.
27
+ */
28
+ definePageMeta({ name: "product-detail" });
29
+
30
+ // Every composable is called before the first await. On the server the async
31
+ // context is lost across an arbitrary await, and anything reaching for
32
+ // useState() or useRequestHeaders() afterwards throws — see the same note in
33
+ // useStorefront().
34
+ const route = useRoute();
35
+ const { render, store } = useStorefront();
36
+ const { setRenderContext } = useRenderContext();
37
+ const { mediaUrl } = useMediaUrl();
38
+ const origin = useRequestURL().origin;
39
+
40
+ // Both fetches start here; useAsyncData runs its handler immediately, so
41
+ // awaiting them one after the other still overlaps the two round trips.
42
+ const productResource = useProductResource();
43
+ const renderData = useAsyncData(`render:${route.path}`, () => render(route.path));
44
+
45
+ await productResource.asyncData;
46
+ const { data: rendered, error } = await renderData;
47
+
48
+ // The 404 thrown inside useProductResource's fetch only lands in its `error`
49
+ // ref — useAsyncData doesn't rethrow. Surface it here. From this point on the
50
+ // product exists; the only question is which sections to use.
51
+ const productError = productResource.error.value;
52
+ if (!productResource.asyncData.data.value) {
53
+ throw createError({
54
+ statusCode: productError?.statusCode === 500 ? 500 : 404,
55
+ statusMessage: productError?.statusCode === 500 ? "Couldn't load this product" : "Product not found",
56
+ fatal: true,
57
+ });
58
+ }
59
+
60
+ const MAIN = "MAIN_PRODUCT";
61
+ const defaultSections = () => [
62
+ { key: "main-product", type: MAIN, settings: {}, blocks: [] },
63
+ { key: "product-recommendations", type: "PRODUCT_RECOMMENDATIONS", settings: {}, blocks: [] },
64
+ ];
65
+
66
+ const templateName = (payload) => {
67
+ const t = payload?.template;
68
+ return String((t && typeof t === "object" ? t.name : t) ?? "").toUpperCase();
69
+ };
70
+
71
+ /**
72
+ * render() answers a path it can't route with the NOT_FOUND template (200) or
73
+ * a 404 — not the product page. That's the difference between the preview
74
+ * (the dashboard sends the PRODUCT template's sections directly) and the live
75
+ * page (which only gets them if resolve_path() matches /<category>/<slug>).
76
+ * Only a PRODUCT payload counts.
77
+ */
78
+ const isProductPayload = (payload) => {
79
+ if (!payload) return false;
80
+ const name = templateName(payload);
81
+ return !name || name === "PRODUCT";
82
+ };
83
+
84
+ /**
85
+ * Not routed as /<category>/<slug>? Every product shares the PRODUCT template,
86
+ * so the same product under /products/<slug> — the path resolve_path() has
87
+ * always matched — returns exactly the merchant's sections.
88
+ */
89
+ const productSlug = String(route.params.product ?? "");
90
+ const altPath = productSlug && route.params.category !== "products" ? `/products/${productSlug}` : null;
91
+ const alt =
92
+ !isProductPayload(rendered.value) && altPath
93
+ ? await useAsyncData(`render:${altPath}`, () => render(altPath))
94
+ : { data: ref(null) };
95
+
96
+ const source = computed(() => {
97
+ if (isProductPayload(rendered.value)) return rendered.value;
98
+ if (isProductPayload(alt.data.value)) return alt.data.value;
99
+ return null;
100
+ });
101
+
102
+ /** What the page renders: the template's sections, MAIN_PRODUCT always there. */
103
+ const data = computed(() => {
104
+ const base = source.value;
105
+ const sections = base?.sections ?? [];
106
+ const product = productResource.product.value;
107
+ return {
108
+ ...(base ?? { template: "PRODUCT", seo: {} }),
109
+ resource: base?.resource ?? (product ? { type: "PRODUCT", id: product.id, slug: productSlug } : null),
110
+ sections: !sections.length
111
+ ? defaultSections()
112
+ : sections.some((section) => section.type === MAIN)
113
+ ? sections
114
+ : [{ key: "main-product", type: MAIN, settings: {}, blocks: [] }, ...sections],
115
+ };
116
+ });
117
+
118
+ if (import.meta.dev && !isProductPayload(rendered.value)) {
119
+ console.warn(
120
+ `[product] render() did not resolve ${route.path} to PRODUCT` +
121
+ (source.value ? `; using the template from ${altPath}.` : "; using the default layout.") +
122
+ " resolve_path() needs to match /<category>/<slug> (last, after the prefixes).",
123
+ error.value ?? ""
124
+ );
125
+ }
126
+
127
+ setRenderContext(data.value);
128
+ watch(data, (payload) => setRenderContext(payload));
129
+
130
+ /* ------------------------------------------------------------------- SEO */
131
+
132
+ const product = productResource.product;
133
+ const seo = computed(() => data.value?.seo ?? {});
134
+
135
+ const description = computed(() => {
136
+ const explicit = seo.value.description;
137
+ if (explicit) return explicit;
138
+ return String(product.value?.description ?? "")
139
+ .replace(/<[^>]*>?/gm, "")
140
+ .trim()
141
+ .slice(0, 300);
142
+ });
143
+
144
+ const image = computed(() => {
145
+ if (seo.value.image) return seo.value.image;
146
+ const path = product.value?.main_image?.path;
147
+ return path ? mediaUrl(path) : undefined;
148
+ });
149
+
150
+ const title = computed(
151
+ () =>
152
+ seo.value.title_full ??
153
+ seo.value.title ??
154
+ [product.value?.name, store.value?.name].filter(Boolean).join(" | ")
155
+ );
156
+
157
+ // view_item / ViewContent — once per product per visit to the page. `product`
158
+ // can change under the same route component (client-side navigation between
159
+ // two products), hence the watch rather than onMounted.
160
+ const analytics = useAnalytics();
161
+ if (import.meta.client) {
162
+ watch(
163
+ () => product.value?.id,
164
+ () => {
165
+ const current = product.value;
166
+ if (current && !current.is_placeholder) analytics.viewItem(current);
167
+ },
168
+ { immediate: true, flush: "post" }
169
+ );
170
+ }
171
+
172
+ useHead({
173
+ title,
174
+ titleTemplate: (value) => value ?? "",
175
+ meta: () => [
176
+ { name: "description", content: description.value || undefined },
177
+ { property: "og:type", content: "product" },
178
+ { property: "og:title", content: product.value?.name || undefined },
179
+ { property: "og:description", content: description.value || undefined },
180
+ { property: "og:image", content: image.value },
181
+ { name: "twitter:card", content: "summary_large_image" },
182
+ { name: "twitter:title", content: product.value?.name || undefined },
183
+ { name: "twitter:description", content: description.value || undefined },
184
+ { name: "twitter:image", content: image.value },
185
+ { name: "robots", content: seo.value.no_index ? "noindex, nofollow" : "index, follow" },
186
+ ],
187
+ link: () =>
188
+ seo.value.canonical_path ? [{ rel: "canonical", href: `${origin}${seo.value.canonical_path}` }] : [],
189
+ });
190
+ </script>
191
+
192
+ <template>
193
+ <div>
194
+ <SectionRenderer :sections="data.sections" />
195
+ </div>
196
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup>
2
+ /**
3
+ * Theme Builder Preview Page.
4
+ *
5
+ * Lightweight shell rendered inside the dashboard's iframe. Does NOT make its
6
+ * own API calls — the builder sends the full state immediately after the READY
7
+ * handshake via postMessage, and every subsequent edit pushes a new STATE.
8
+ *
9
+ * This keeps first-paint fast (no SSR data-fetch) and guarantees the preview
10
+ * is always driven by the builder's in-memory state, not a stale DB snapshot.
11
+ */
12
+ import { BuilderOverlay } from "#components";
13
+
14
+ const { sectionsFrom } = useBuilderPreview();
15
+ const sections = sectionsFrom(ref([]));
16
+ </script>
17
+
18
+ <template>
19
+ <div>
20
+ <SectionRenderer v-if="sections?.length" :sections="sections" />
21
+ <div v-else class="osi-empty">
22
+ <p>Waiting for builder…</p>
23
+ </div>
24
+
25
+ <!-- Hover / selection outlines, name tags, toolbar and "+" buttons. -->
26
+ <BuilderOverlay />
27
+ </div>
28
+ </template>
29
+
30
+ <style scoped>
31
+ .osi-empty {
32
+ padding: 6rem 1rem;
33
+ text-align: center;
34
+ color: var(--osi-text, #999);
35
+ opacity: 0.7;
36
+ font-family: system-ui, sans-serif;
37
+ }
38
+ </style>