@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,411 @@
1
+ import { NuxtLink } from "#components";
2
+ import { ICON_PRESETS } from "#osi/utils/icon-presets";
3
+ import { useProductDetail } from "#osi/composables/useProductDetail";
4
+ import { buttonClasses, buttonVars } from "#osi/composables/useButtonStyle";
5
+
6
+ export default function useLogic({ props }) {
7
+ const detail = useProductDetail();
8
+ const t = useT();
9
+ const { mediaUrl } = useMediaUrl();
10
+ const { addToWishlist, wishlistStatus } = useWishlist();
11
+ const accordion = inject("osi-pdp-accordion", null);
12
+
13
+ const type = computed(() => props.block.type);
14
+ const s = computed(() => props.block.settings ?? {});
15
+
16
+ /* ------------------------------------------------------------ helpers */
17
+
18
+ const has = (v) => v !== undefined && v !== null && v !== "";
19
+ const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
20
+ const bool = (v, d) => (has(v) ? v === true || v === "true" : d);
21
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => has(v)));
22
+
23
+ /* ----------------------------------------------------------- the box */
24
+
25
+ /** Space under each block type when the merchant hasn't set one. */
26
+ const TYPE_SPACING = {
27
+ BREADCRUMBS: 12,
28
+ TITLE: 6,
29
+ PRICE: 20,
30
+ VARIANT_PICKER: 24,
31
+ QUANTITY: 16,
32
+ BUY_BUTTONS: 24,
33
+ DESCRIPTION: 24,
34
+ TEXT: 16,
35
+ COLLAPSIBLE: 0,
36
+ ICON_TEXT: 10,
37
+ INVENTORY: 12,
38
+ PRODUCT_META: 16,
39
+ SHARE: 16,
40
+ DIVIDER: 0,
41
+ BUTTON: 16,
42
+ MEDIA: 16,
43
+ CUSTOM_HTML: 16,
44
+ };
45
+
46
+ const ALIGN = {
47
+ START: { text: "start", items: "flex-start" },
48
+ CENTER: { text: "center", items: "center" },
49
+ END: { text: "end", items: "flex-end" },
50
+ };
51
+
52
+ const blockStyle = computed(() => {
53
+ const v = s.value;
54
+ const bottomDefault = `${TYPE_SPACING[type.value] ?? 16}px`;
55
+ const align = ALIGN[v.BLOCK_ALIGN];
56
+ return compact({
57
+ "--b-pt": px(v.BLOCK_PADDING_TOP) ?? "0px",
58
+ "--b-pb": px(v.BLOCK_PADDING_BOTTOM) ?? bottomDefault,
59
+ "--b-pl": px(v.BLOCK_PADDING_LEFT) ?? "0px",
60
+ "--b-pr": px(v.BLOCK_PADDING_RIGHT) ?? "0px",
61
+ "--b-pt-m": px(v.BLOCK_PADDING_TOP_MOBILE),
62
+ "--b-pb-m": px(v.BLOCK_PADDING_BOTTOM_MOBILE),
63
+ "--b-pl-m": px(v.BLOCK_PADDING_LEFT_MOBILE),
64
+ "--b-pr-m": px(v.BLOCK_PADDING_RIGHT_MOBILE),
65
+ "--b-bg": v.BLOCK_BG_COLOR,
66
+ // Inside padding of the box (only when it has a background or border).
67
+ "--b-box-p": px(v.BLOCK_BOX_PADDING),
68
+ "--b-radius": px(v.BLOCK_RADIUS),
69
+ "--b-border": has(v.BLOCK_BORDER_WIDTH) && Number(v.BLOCK_BORDER_WIDTH) > 0
70
+ ? `${Number(v.BLOCK_BORDER_WIDTH)}px solid ${v.BLOCK_BORDER_COLOR || "var(--osi-border, #e5e5e5)"}`
71
+ : undefined,
72
+ color: v.BLOCK_TEXT_COLOR,
73
+ // Alignment is inherited from the column unless this block sets its own.
74
+ "--osi-pdp-justify": align?.items,
75
+ "--osi-pdp-text-align": align?.text,
76
+ });
77
+ });
78
+
79
+ const boxed = computed(() => !!(s.value.BLOCK_BG_COLOR || Number(s.value.BLOCK_BORDER_WIDTH) > 0));
80
+
81
+ const blockClasses = computed(() => [
82
+ `osi-pdp-block--${String(type.value).toLowerCase().replace(/_/g, "-")}`,
83
+ `osi-pdp-block--show-${String(s.value.BLOCK_VISIBILITY || "ALL").toLowerCase()}`,
84
+ { "osi-pdp-block--boxed": boxed.value },
85
+ s.value.BLOCK_CUSTOM_CLASS,
86
+ ]);
87
+
88
+ /* -------------------------------------------------------- text styles */
89
+
90
+ const titleStyle = useTextStyle(() => s.value, {
91
+ prefix: "TITLE_TEXT",
92
+ defaults: { size: 28, weight: "REGULAR", font: "HEADING", lineHeight: 120 },
93
+ });
94
+ const priceStyle = useTextStyle(() => s.value, {
95
+ prefix: "PRICE_TEXT",
96
+ defaults: { size: 18, weight: "REGULAR", font: "BODY" },
97
+ });
98
+ const bodyStyle = useTextStyle(() => s.value, {
99
+ prefix: type.value === "DESCRIPTION" ? "DESCRIPTION_TEXT" : type.value === "TEXT" ? "TEXT_STYLE" : "BLOCK_TEXT",
100
+ defaults: { size: 14, lineHeight: 170 },
101
+ });
102
+ const smallText = useTextStyle(() => s.value, {
103
+ prefix: "BLOCK_TEXT",
104
+ defaults: { size: 12, lineHeight: 150 },
105
+ });
106
+ // Small labels (crumbs, meta, share) are uppercase by design; only an explicit
107
+ // BLOCK_TEXT_TRANSFORM changes that.
108
+ const smallStyle = computed(() => {
109
+ const { textTransform, ...rest } = smallText.value;
110
+ return s.value.BLOCK_TEXT_TRANSFORM ? smallText.value : rest;
111
+ });
112
+
113
+ /* -------------------------------------------------------------- icons */
114
+
115
+ const ICON_MAP = Object.fromEntries(ICON_PRESETS.map((icon) => [icon.name, icon.svg]));
116
+ const ICON_MAP_LOWER = Object.fromEntries(
117
+ ICON_PRESETS.map((icon) => [icon.name.toLowerCase().replace(/\s+/g, "_"), icon.svg])
118
+ );
119
+
120
+ const icon = computed(() => {
121
+ if (s.value.ICON_SVG) return s.value.ICON_SVG;
122
+ const key = s.value.ICON_PRESET;
123
+ if (!key) return "";
124
+ return ICON_MAP[key] || ICON_MAP_LOWER[String(key).toLowerCase().replace(/\s+/g, "_")] || "";
125
+ });
126
+ const iconStyle = computed(() =>
127
+ compact({ "--icon-size": px(s.value.ICON_SIZE), "--icon-color": s.value.ICON_COLOR })
128
+ );
129
+
130
+ /* -------------------------------------------------------------- title */
131
+
132
+ const titleActions = computed(() => String(s.value.TITLE_ACTIONS || "NONE").toUpperCase());
133
+ const showTitleShare = computed(() => ["SHARE", "BOTH"].includes(titleActions.value));
134
+ const showTitleWishlist = computed(() => ["WISHLIST", "BOTH"].includes(titleActions.value));
135
+ const actionStyle = computed(() => String(s.value.TITLE_ACTION_STYLE || "ICON").toLowerCase());
136
+
137
+ const wishlisted = computed(
138
+ () => wishlistStatus.value[detail?.product.value?.id] ?? detail?.product.value?.wishlisted ?? false
139
+ );
140
+
141
+ /* -------------------------------------------------------------- price */
142
+
143
+ const priceVars = computed(() =>
144
+ compact({
145
+ "--price-sale": s.value.PRICE_SALE_COLOR,
146
+ "--price-compare": s.value.PRICE_COMPARE_COLOR,
147
+ "--price-badge-bg": s.value.PRICE_BADGE_BG,
148
+ "--price-badge-fg": s.value.PRICE_BADGE_COLOR,
149
+ })
150
+ );
151
+ const comparePosition = computed(() => (s.value.PRICE_COMPARE_POSITION === "AFTER" ? "AFTER" : "BEFORE"));
152
+ const badgeStyle = computed(() => String(s.value.PRICE_BADGE_STYLE || "OUTLINE").toLowerCase());
153
+
154
+ /* -------------------------------------------------------- description */
155
+
156
+ const expanded = ref(false);
157
+ const clampable = computed(() => type.value === "DESCRIPTION" && !!s.value.DESCRIPTION_COLLAPSE);
158
+ const clampStyle = computed(() =>
159
+ clampable.value && !expanded.value
160
+ ? { "--osi-pdp-clamp": String(Number(s.value.DESCRIPTION_CLAMP ?? 4)) }
161
+ : {}
162
+ );
163
+
164
+ /**
165
+ * "Read more" only when there is more.
166
+ *
167
+ * Clamping is a CSS effect, so nothing in the settings can tell whether this
168
+ * particular description actually overflows — a two-line one was still getting
169
+ * a button that expanded nothing. The element is measured instead: taller than
170
+ * its clamped box means there's hidden text.
171
+ */
172
+ const rte = ref(null);
173
+ const overflows = ref(false);
174
+ let observer = null;
175
+
176
+ function measure() {
177
+ const el = rte.value;
178
+ if (!el || !clampable.value) {
179
+ overflows.value = false;
180
+ return;
181
+ }
182
+ if (expanded.value) return; // already open; keep the button so it can close
183
+ overflows.value = el.scrollHeight - el.clientHeight > 1;
184
+ }
185
+
186
+ if (import.meta.client) {
187
+ onMounted(async () => {
188
+ await nextTick();
189
+ measure();
190
+ if (typeof ResizeObserver !== "undefined" && rte.value) {
191
+ // Fonts loading and the column changing width both change the line count.
192
+ observer = new ResizeObserver(measure);
193
+ observer.observe(rte.value);
194
+ }
195
+ window.addEventListener("resize", measure);
196
+ });
197
+ onBeforeUnmount(() => {
198
+ observer?.disconnect();
199
+ window.removeEventListener("resize", measure);
200
+ });
201
+ watch(
202
+ () => [detail?.product.value?.id, s.value.DESCRIPTION_CLAMP, clampable.value].join("|"),
203
+ async () => {
204
+ expanded.value = false;
205
+ await nextTick();
206
+ measure();
207
+ }
208
+ );
209
+ }
210
+
211
+ const showMoreButton = computed(() => clampable.value && (overflows.value || expanded.value));
212
+
213
+ /* -------------------------------------------------------- collapsible */
214
+
215
+ const localOpen = ref(!!s.value.COLLAPSIBLE_OPEN);
216
+ const blockId = computed(() => props.block.id ?? props.block.key);
217
+
218
+ // Section setting ACCORDION_ONE_OPEN: opening one closes the others.
219
+ if (accordion && s.value.COLLAPSIBLE_OPEN && !accordion.openId.value) {
220
+ accordion.openId.value = blockId.value;
221
+ }
222
+ const open = computed({
223
+ get: () => (accordion?.single.value ? accordion.openId.value === blockId.value : localOpen.value),
224
+ set: (value) => {
225
+ if (accordion?.single.value) accordion.openId.value = value ? blockId.value : null;
226
+ else localOpen.value = value;
227
+ },
228
+ });
229
+
230
+ const collapsibleBody = computed(() => {
231
+ const policies = detail?.policies.value ?? {};
232
+ switch (s.value.COLLAPSIBLE_SOURCE) {
233
+ case "SHIPPING_POLICY":
234
+ return policies.shipping ?? "";
235
+ case "RETURN_POLICY":
236
+ return policies.return_exchange ?? "";
237
+ case "PRIVACY_POLICY":
238
+ return policies.privacy ?? "";
239
+ case "DESCRIPTION":
240
+ return detail?.product.value?.description ?? "";
241
+ default:
242
+ return t(s.value.COLLAPSIBLE_CONTENT) ?? "";
243
+ }
244
+ });
245
+ const hideEmpty = computed(
246
+ () => type.value === "COLLAPSIBLE" && s.value.COLLAPSIBLE_HIDE_EMPTY !== false && !String(collapsibleBody.value).trim()
247
+ );
248
+
249
+ /**
250
+ * Does this block have anything to draw?
251
+ *
252
+ * Every type used to keep its wrapper even when its own content was empty —
253
+ * a product with no description, a reassurance line with no text, a picker on
254
+ * a product without options — and the wrapper's padding left a band of empty
255
+ * space in the column. An empty block now renders nothing at all.
256
+ */
257
+ const text = (v) => String(v ?? "").replace(/<[^>]*>/g, "").trim();
258
+
259
+ const hasContent = computed(() => {
260
+ const product = detail?.product.value;
261
+ switch (type.value) {
262
+ // No price: no price block (never "$0").
263
+ case "PRICE":
264
+ return !!detail?.priceText.value;
265
+ case "BREADCRUMBS":
266
+ return (detail?.breadcrumbs.value ?? []).length > 0;
267
+ case "TITLE":
268
+ return !!product?.name;
269
+ case "DESCRIPTION":
270
+ return !!text(product?.description);
271
+ case "TEXT":
272
+ return !!text(t(s.value.TEXT_CONTENT));
273
+ case "ICON_TEXT":
274
+ return !!(text(t(s.value.ICON_TEXT_LABEL)) || icon.value);
275
+ case "VARIANT_PICKER":
276
+ return !!detail?.hasOptions.value;
277
+ case "PRODUCT_META":
278
+ return !!(
279
+ (s.value.META_SHOW_SKU !== false && detail?.sku.value) ||
280
+ (s.value.META_SHOW_BRAND && brand.value) ||
281
+ (s.value.META_SHOW_CATEGORY && category.value)
282
+ );
283
+ case "MEDIA":
284
+ return !!imageSrc.value;
285
+ case "CUSTOM_HTML":
286
+ return !!text(s.value.HTML);
287
+ case "BUTTON":
288
+ return !!(linkButtonUrl.value && text(t(s.value.LABEL)));
289
+ case "COLLAPSIBLE":
290
+ return !hideEmpty.value;
291
+ default:
292
+ return true;
293
+ }
294
+ });
295
+
296
+ /* ----------------------------------------------------------- inventory */
297
+
298
+ const inventoryState = computed(() => {
299
+ if (!detail) return "IN";
300
+ if (detail.isComingSoon.value) return "SOON";
301
+ if (detail.isOutOfStock.value) return "OUT";
302
+ if (detail.isLowStock.value) return "LOW";
303
+ return "IN";
304
+ });
305
+ const inventoryColor = computed(
306
+ () =>
307
+ ({
308
+ IN: s.value.INVENTORY_IN_COLOR || "#16a34a",
309
+ LOW: s.value.INVENTORY_LOW_COLOR || "#d97706",
310
+ OUT: s.value.INVENTORY_OUT_COLOR || "#dc2626",
311
+ SOON: s.value.INVENTORY_LOW_COLOR || "#d97706",
312
+ })[inventoryState.value]
313
+ );
314
+
315
+ /* ---------------------------------------------------------------- meta */
316
+
317
+ const brand = computed(() => detail?.product.value?.brand?.name ?? "");
318
+ const category = computed(() => detail?.product.value?.categories?.[0]?.category?.name ?? "");
319
+
320
+ /* --------------------------------------------------------------- share */
321
+
322
+ const shareStyle = computed(() => String(s.value.SHARE_STYLE || "TEXT").toUpperCase());
323
+
324
+ /**
325
+ * Share opens the device's own share sheet (navigator.share). Desktop browsers
326
+ * often don't have one — there the block opens a small menu with the networks
327
+ * and "copy link" instead of silently copying, which looked like nothing had
328
+ * happened.
329
+ */
330
+ const shareMenuOpen = ref(false);
331
+
332
+ async function share() {
333
+ const native = import.meta.client && typeof navigator !== "undefined" && !!navigator.share;
334
+ if (native) {
335
+ await detail.shareProduct();
336
+ return;
337
+ }
338
+ shareMenuOpen.value = !shareMenuOpen.value;
339
+ }
340
+
341
+ function closeShareMenu() {
342
+ shareMenuOpen.value = false;
343
+ }
344
+
345
+ onMounted(() => document.addEventListener("click", onDocumentClick, true));
346
+ onBeforeUnmount(() => document.removeEventListener("click", onDocumentClick, true));
347
+
348
+ const shareRoot = ref(null);
349
+ function onDocumentClick(event) {
350
+ if (shareMenuOpen.value && shareRoot.value && !shareRoot.value.contains(event.target)) {
351
+ closeShareMenu();
352
+ }
353
+ }
354
+ const shareUrl = computed(() => (import.meta.client ? window.location.href : ""));
355
+ const networks = computed(() => {
356
+ const url = encodeURIComponent(shareUrl.value);
357
+ const name = encodeURIComponent(detail?.product.value?.name ?? "");
358
+ const image = encodeURIComponent(mediaUrl(detail?.product.value?.main_image) ?? "");
359
+ const all = [
360
+ { key: "FACEBOOK", label: "Facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
361
+ path: "M14 8h3V4h-3a4 4 0 0 0-4 4v2H8v4h2v6h4v-6h3l1-4h-4V8.5a.5.5 0 0 1 .5-.5Z" },
362
+ { key: "X", label: "X", href: `https://twitter.com/intent/tweet?url=${url}&text=${name}`,
363
+ path: "M4 4l16 16M20 4 4 20" },
364
+ { key: "WHATSAPP", label: "WhatsApp", href: `https://wa.me/?text=${name}%20${url}`,
365
+ path: "M4 20l1.3-4A8 8 0 1 1 8 18.7L4 20Zm5-11c0 3 3 6 6 6l1.5-1.5-2-1-1 .8c-1-.4-2.4-1.8-2.8-2.8l.8-1-1-2L9 9Z" },
366
+ { key: "PINTEREST", label: "Pinterest", href: `https://pinterest.com/pin/create/button/?url=${url}&media=${image}&description=${name}`,
367
+ path: "M12 3a9 9 0 0 0-3.3 17.4c-.1-.8 0-1.9.2-2.7l1.2-5s-.3-.6-.3-1.5c0-1.4.8-2.5 1.8-2.5.9 0 1.3.7 1.3 1.5 0 .9-.6 2.2-.9 3.4-.2 1 .5 1.9 1.6 1.9 1.9 0 3.3-2 3.3-4.9 0-2.6-1.8-4.4-4.5-4.4-3 0-4.8 2.3-4.8 4.6 0 .9.4 1.9.8 2.4" },
368
+ { key: "EMAIL", label: "Email", href: `mailto:?subject=${name}&body=${url}`,
369
+ path: "M3 6h18v12H3zM3 7l9 6 9-6" },
370
+ ];
371
+ return all.filter((n) => bool(s.value[`SHARE_${n.key}`], true));
372
+ });
373
+
374
+ /* --------------------------------------------------------------- other */
375
+
376
+ const dividerStyle = computed(() =>
377
+ compact({
378
+ "--div-style": { DASHED: "dashed", DOTTED: "dotted", SPACE: "none" }[s.value.DIVIDER_STYLE] ?? "solid",
379
+ "--div-color": s.value.DIVIDER_COLOR,
380
+ "--div-w": px(s.value.DIVIDER_THICKNESS),
381
+ "--div-width": has(s.value.DIVIDER_WIDTH) ? `${Number(s.value.DIVIDER_WIDTH)}%` : undefined,
382
+ "--div-space": px(s.value.DIVIDER_SPACE),
383
+ })
384
+ );
385
+
386
+ const linkButtonStyle = computed(() => buttonVars(s.value, "BUTTON"));
387
+
388
+ /** LINK_TO_PRODUCT: the product's own page (FEATURED_PRODUCT's "View full details"). */
389
+ const productUrl = computed(() => {
390
+ const p = detail?.product.value;
391
+ if (!p || p.is_placeholder) return null;
392
+ if (p.url) return p.url;
393
+ const category = p.categories?.[0]?.category?.slugified_name;
394
+ return p.slugified_name ? `/${category || "products"}/${p.slugified_name}` : null;
395
+ });
396
+ const linkButtonUrl = computed(() => (s.value.LINK_TO_PRODUCT ? productUrl.value : s.value.URL) || null);
397
+ const linkButtonClasses = computed(() =>
398
+ buttonClasses(s.value, "BUTTON", s.value.BUTTON_FULL_WIDTH ? ["osi-btn--block"] : [])
399
+ );
400
+
401
+ const NuxtLinkComponent = NuxtLink;
402
+ const imageSrc = computed(() => mediaUrl(props.block.media ?? s.value.MEDIA ?? null));
403
+ const imageStyle = computed(() =>
404
+ compact({
405
+ "--img-width": has(s.value.IMAGE_WIDTH) ? `${Number(s.value.IMAGE_WIDTH)}%` : undefined,
406
+ "--img-radius": px(s.value.IMAGE_RADIUS),
407
+ })
408
+ );
409
+
410
+ return { detail, t, mediaUrl, addToWishlist, wishlistStatus, accordion, type, s, has, px, bool, compact, TYPE_SPACING, ALIGN, blockStyle, boxed, blockClasses, titleStyle, priceStyle, bodyStyle, smallText, smallStyle, ICON_MAP, ICON_MAP_LOWER, icon, iconStyle, titleActions, showTitleShare, showTitleWishlist, actionStyle, wishlisted, priceVars, comparePosition, badgeStyle, expanded, clampable, clampStyle, rte, overflows, observer, measure, showMoreButton, localOpen, blockId, open, collapsibleBody, hideEmpty, text, hasContent, inventoryState, inventoryColor, brand, category, shareStyle, shareMenuOpen, share, closeShareMenu, shareRoot, onDocumentClick, shareUrl, networks, dividerStyle, linkButtonStyle, productUrl, linkButtonUrl, linkButtonClasses, NuxtLinkComponent, imageSrc, imageStyle };
411
+ }
@@ -0,0 +1,12 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const root = ref(null);
4
+
5
+ onMounted(async () => {
6
+ await nextTick();
7
+ const target = root.value?.querySelectorAll("[data-lightbox-item]")?.[props.startIndex];
8
+ target?.scrollIntoView({ block: "start", behavior: "auto" });
9
+ });
10
+
11
+ return { root };
12
+ }
@@ -0,0 +1,31 @@
1
+ import { useProductDetail } from "#osi/composables/useProductDetail";
2
+
3
+ export default function useLogic({ props }) {
4
+ const detail = useProductDetail();
5
+ const t = useT();
6
+ const s = computed(() => props.block.settings ?? {});
7
+
8
+ const label = computed(() => t(s.value.QUANTITY_LABEL) || "Quantity");
9
+ const showLabel = computed(() => s.value.QUANTITY_SHOW_LABEL !== false);
10
+
11
+ const has = (v) => v !== undefined && v !== null && v !== "";
12
+ const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
13
+ const stepperStyle = computed(() =>
14
+ Object.fromEntries(
15
+ Object.entries({
16
+ "--qty-h": px(s.value.QUANTITY_HEIGHT),
17
+ "--qty-w": px(s.value.QUANTITY_WIDTH),
18
+ "--qty-radius": px(s.value.QUANTITY_RADIUS),
19
+ "--qty-bg": s.value.QUANTITY_BG,
20
+ "--qty-fg": s.value.QUANTITY_TEXT_COLOR,
21
+ "--qty-border": s.value.QUANTITY_BORDER_COLOR,
22
+ }).filter(([, v]) => has(v))
23
+ )
24
+ );
25
+
26
+ const atMax = computed(
27
+ () => !!detail.maxQuantity.value && detail.quantity.value >= detail.maxQuantity.value
28
+ );
29
+
30
+ return { detail, t, s, label, showLabel, has, px, stepperStyle, atMax };
31
+ }
@@ -0,0 +1,125 @@
1
+ import { useProductDetail } from "#osi/composables/useProductDetail";
2
+
3
+ export default function useLogic({ props }) {
4
+ const detail = useProductDetail();
5
+ const t = useT();
6
+ const { mediaUrl } = useMediaUrl();
7
+ const s = computed(() => props.block.settings ?? {});
8
+
9
+ const has = (v) => v !== undefined && v !== null && v !== "";
10
+ const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
11
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => has(v)));
12
+
13
+ const WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
14
+ const CASES = { NONE: "none", UPPERCASE: "uppercase", LOWERCASE: "lowercase", CAPITALIZE: "capitalize" };
15
+
16
+ const style = computed(() => String(s.value.PICKER_STYLE || "SWATCHES").toUpperCase());
17
+ const colorDisplay = computed(() => String(s.value.PICKER_COLOR_DISPLAY || "SWATCH").toUpperCase());
18
+ const soldOut = computed(() => s.value.PICKER_SOLD_OUT_STYLE || "DIM");
19
+ const showLabel = computed(() => s.value.PICKER_SHOW_LABEL !== false);
20
+ const showValue = computed(() => s.value.PICKER_SHOW_VALUE !== false);
21
+ const labelInline = computed(() => s.value.PICKER_LABEL_LAYOUT === "INLINE");
22
+ const allowDeselect = computed(() => s.value.PICKER_ALLOW_DESELECT !== false);
23
+ const selectedLook = computed(() => String(s.value.PICKER_SELECTED_STYLE || "OUTLINE").toLowerCase());
24
+
25
+ const labelStyle = useTextStyle(() => s.value, {
26
+ prefix: "PICKER_LABEL",
27
+ defaults: { size: 12, weight: "REGULAR", transform: "UPPERCASE", lineHeight: 140 },
28
+ });
29
+
30
+ const pickerStyle = computed(() =>
31
+ compact({
32
+ "--sw-size": px(s.value.PICKER_SWATCH_SIZE) ?? "32px",
33
+ "--sw-radius": { SQUARE: "2px", ROUNDED: "8px" }[s.value.PICKER_SWATCH_SHAPE] ?? "999px",
34
+ "--sw-ring": s.value.PICKER_SWATCH_RING_COLOR,
35
+ "--pk-gap": px(s.value.PICKER_GAP) ?? "8px",
36
+ "--pk-option-gap": px(s.value.PICKER_OPTION_GAP) ?? "16px",
37
+ "--pk-cols": has(s.value.PICKER_BLOCK_COLUMNS) ? Number(s.value.PICKER_BLOCK_COLUMNS) : 4,
38
+
39
+ "--pill-h": px(s.value.PICKER_PILL_HEIGHT) ?? "40px",
40
+ "--pill-px": px(s.value.PICKER_PILL_PADDING_X) ?? "14px",
41
+ "--pill-min": px(s.value.PICKER_PILL_MIN_WIDTH) ?? "44px",
42
+ "--pill-radius": px(s.value.PICKER_PILL_RADIUS),
43
+ "--pill-fs": px(s.value.PICKER_PILL_FONT_SIZE) ?? "12px",
44
+ "--pill-fw": WEIGHTS[s.value.PICKER_PILL_WEIGHT],
45
+ "--pill-tt": CASES[s.value.PICKER_PILL_TRANSFORM] ?? "uppercase",
46
+ "--pill-bg": s.value.PICKER_PILL_BG,
47
+ "--pill-fg": s.value.PICKER_PILL_TEXT,
48
+ "--pill-border": s.value.PICKER_PILL_BORDER,
49
+ "--pill-sel-bg": s.value.PICKER_SELECTED_BG,
50
+ "--pill-sel-fg": s.value.PICKER_SELECTED_TEXT,
51
+ "--pill-sel-border": s.value.PICKER_SELECTED_BORDER,
52
+ "--pk-error": s.value.PICKER_ERROR_COLOR,
53
+ })
54
+ );
55
+
56
+ const isColor = (type) => ["color", "colour"].includes(String(type?.name ?? "").toLowerCase());
57
+
58
+ /** How one option type is drawn. */
59
+ function modeFor(type) {
60
+ if (style.value === "DROPDOWN") return "DROPDOWN";
61
+ if (style.value === "RADIO") return "RADIO";
62
+ if (style.value === "BLOCKS") return "BLOCKS";
63
+ if (style.value === "SWATCHES" && isColor(type)) {
64
+ return { SWATCH_NAME: "SWATCH_NAME", IMAGE: "IMAGE", PILL: "PILLS" }[colorDisplay.value] ?? "SWATCH";
65
+ }
66
+ return "PILLS";
67
+ }
68
+
69
+ function valuesFor(type) {
70
+ const values = detail.applyCombinationLogic(type.id) ?? [];
71
+ return soldOut.value === "HIDE" ? values.filter((value) => value.isAvailable) : values;
72
+ }
73
+
74
+ function selectedName(type) {
75
+ const id = detail.selectedOptions.value[type.id];
76
+ if (!id) return "";
77
+ return (type.values ?? []).find((value) => value.id === id)?.name ?? "";
78
+ }
79
+
80
+ function choose(value) {
81
+ if (!allowDeselect.value && detail.isSelected(value)) return;
82
+ detail.selectVariant(value);
83
+ }
84
+
85
+ function optionsFor(type) {
86
+ return valuesFor(type).map((value) => ({
87
+ label: value.isAvailable ? value.name : `${value.name} — sold out`,
88
+ value: value.id,
89
+ disabled: !value.isAvailable,
90
+ }));
91
+ }
92
+
93
+ function pickFromDropdown(type, id) {
94
+ const value = (detail.applyCombinationLogic(type.id) ?? []).find((entry) => entry.id === id);
95
+ if (value && !detail.isSelected(value)) detail.selectVariant(value);
96
+ }
97
+
98
+ /** A picture for the IMAGE colour mode: the value's own, else a variant's. */
99
+ function imageFor(value) {
100
+ const own = value?.image?.path ? value.image : value?.image;
101
+ if (own) return mediaUrl(own);
102
+ const variants = detail.product.value?.variants ?? [];
103
+ const match = variants.find((v) => (v.values ?? []).some((x) => x.id === value.id) && (v.images?.length || v.image));
104
+ return match ? mediaUrl(match.images?.[0] ?? match.image) : null;
105
+ }
106
+
107
+ function valueClasses(value) {
108
+ return {
109
+ "is-selected": detail.isSelected(value),
110
+ "is-unavailable": !value.isAvailable,
111
+ "is-struck": !value.isAvailable && soldOut.value === "STRIKE",
112
+ };
113
+ }
114
+
115
+ const sizeGuide = computed(() => ({
116
+ label: t(s.value.PICKER_SIZE_GUIDE_LABEL),
117
+ url: s.value.PICKER_SIZE_GUIDE_URL,
118
+ option: String(s.value.PICKER_SIZE_GUIDE_OPTION || "size").toLowerCase(),
119
+ }));
120
+
121
+ const errorText = (type) =>
122
+ (t(s.value.PICKER_ERROR_TEXT) || "Choose a {option}").replace("{option}", type.name.toLowerCase());
123
+
124
+ return { detail, t, mediaUrl, s, has, px, compact, WEIGHTS, CASES, style, colorDisplay, soldOut, showLabel, showValue, labelInline, allowDeselect, selectedLook, labelStyle, pickerStyle, isColor, modeFor, valuesFor, selectedName, choose, optionsFor, pickFromDropdown, imageFor, valueClasses, sizeGuide, errorText };
125
+ }