@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,126 @@
1
+ import NewsletterServices from "#osi/services/NewsletterServices";
2
+
3
+ export default function useLogic({ props }) {
4
+ const analytics = useAnalytics();
5
+
6
+ const { s, t, shellStyle, innerStyle, align, customClass, heading } =
7
+ useSectionShell(() => props.section);
8
+ const { showApiError } = useApiErrorToast();
9
+
10
+ function toNum(val, fallback) {
11
+ const n = typeof val === "string" ? parseFloat(val) : Number(val);
12
+ return Number.isFinite(n) ? n : fallback;
13
+ }
14
+ function toBool(val, fallback = false) {
15
+ if (val === undefined || val === null || val === "") return fallback;
16
+ if (typeof val === "string") return val.toLowerCase() === "true" || val === "1";
17
+ return !!val;
18
+ }
19
+
20
+ const email = ref("");
21
+ const consent = ref(false);
22
+ const submitting = ref(false);
23
+ const done = ref(false);
24
+ const error = ref("");
25
+
26
+ const requiresConsent = computed(() => !!s.value.SHOW_CONSENT);
27
+ const inline = computed(() => (s.value.LAYOUT ?? "INLINE") === "INLINE");
28
+ const gap = computed(() => toNum(s.value.GAP, 12));
29
+
30
+ // ── input + button styling ──
31
+ // Only what this section set is emitted; empty settings follow the theme's
32
+ // Inputs / Buttons groups (assets/css/osi-controls.css).
33
+ const inputStyle = useInputStyle(() => s.value);
34
+ const inputClasses = useInputClasses(() => s.value);
35
+ const buttonStyle = useButtonStyle(() => s.value);
36
+ const buttonClasses = useButtonClasses(() => s.value);
37
+
38
+ const headingStyle = computed(() => ({
39
+ fontSize: `${toNum(s.value.HEADING_TEXT_SIZE, 28)}px`,
40
+ fontWeight: s.value.HEADING_TEXT_WEIGHT || "600",
41
+ color: s.value.HEADING_TEXT_COLOR || undefined,
42
+ lineHeight: toNum(s.value.HEADING_TEXT_LINE_HEIGHT, 130) / 100,
43
+ }));
44
+ const descStyle = computed(() => ({
45
+ fontSize: `${toNum(s.value.TEXT_STYLE_SIZE, 15)}px`,
46
+ fontWeight: s.value.TEXT_STYLE_WEIGHT || "400",
47
+ color: s.value.TEXT_STYLE_COLOR || undefined,
48
+ lineHeight: toNum(s.value.TEXT_STYLE_LINE_HEIGHT, 130) / 100,
49
+ opacity: s.value.TEXT_STYLE_COLOR ? 1 : 0.8,
50
+ }));
51
+ const sectionLayout = computed(() => s.value.SECTION_LAYOUT || 'CENTERED');
52
+ const isSplit = computed(() => sectionLayout.value === 'SPLIT');
53
+ const headerWidth = computed(() => toNum(s.value.HEADER_WIDTH, 40));
54
+ const columnGap = computed(() => toNum(s.value.COLUMN_GAP, 48));
55
+ // ── raw custom CSS: scoped + injected via useHead ──
56
+ const uid = computed(() => `osi-nl-${props.section.key}`);
57
+
58
+ // Rewrites each top-level selector to be prefixed with this section
59
+ // instance's unique attribute. Deliberately simple (split on top-level
60
+ // `}`/`{`) rather than a full CSS parser — good enough for a handful of
61
+ // flat rules on one element; does NOT handle @media/@keyframes correctly.
62
+ function scopeCss(raw, scopeAttr) {
63
+ if (!raw) return "";
64
+ return raw
65
+ .split("}")
66
+ .map((rule) => rule.trim())
67
+ .filter(Boolean)
68
+ .map((rule) => {
69
+ const [selectorPart, body] = rule.split("{");
70
+ if (!body) return "";
71
+ const scopedSelectors = selectorPart
72
+ .split(",")
73
+ .map((sel) => `[data-osi-nl="${scopeAttr}"] ${sel.trim()}`)
74
+ .join(", ");
75
+ return `${scopedSelectors} { ${body.trim()} }`;
76
+ })
77
+ .join("\n");
78
+ }
79
+
80
+ const scopedButtonCss = computed(() => scopeCss(s.value.BUTTON_CUSTOM_CSS, uid.value));
81
+ const scopedInputCss = computed(() => scopeCss(s.value.INPUT_CUSTOM_CSS, uid.value));
82
+ const combinedCustomCss = computed(() =>
83
+ [scopedButtonCss.value, scopedInputCss.value].filter(Boolean).join("\n")
84
+ );
85
+
86
+ // SSR-safe, dedupes by key (so live-preview edits replace this tag instead
87
+ // of piling up new ones), reactive to setting changes.
88
+ useHead(() => ({
89
+ style: combinedCustomCss.value
90
+ ? [{ key: `${uid.value}-custom-css`, textContent: combinedCustomCss.value }]
91
+ : [],
92
+ }));
93
+
94
+ async function submit() {
95
+ error.value = "";
96
+
97
+ const value = email.value.trim();
98
+ if (!value) {
99
+ error.value = "Enter an email address.";
100
+ return;
101
+ }
102
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
103
+ error.value = "Enter a valid email address.";
104
+ return;
105
+ }
106
+ if (requiresConsent.value && !consent.value) {
107
+ error.value = "Please accept to continue.";
108
+ return;
109
+ }
110
+
111
+ submitting.value = true;
112
+ try {
113
+ await NewsletterServices.subscribe(value);
114
+ analytics.lead("newsletter");
115
+ done.value = true;
116
+ email.value = "";
117
+ consent.value = false;
118
+ } catch (err) {
119
+ error.value = showApiError(err, "Couldn't subscribe right now.");
120
+ } finally {
121
+ submitting.value = false;
122
+ }
123
+ }
124
+
125
+ return { analytics, s, t, shellStyle, innerStyle, align, customClass, heading, showApiError, toNum, toBool, email, consent, submitting, done, error, requiresConsent, inline, gap, inputStyle, inputClasses, buttonStyle, buttonClasses, headingStyle, descStyle, sectionLayout, isSplit, headerWidth, columnGap, uid, scopeCss, scopedButtonCss, scopedInputCss, combinedCustomCss, submit };
126
+ }
@@ -0,0 +1,201 @@
1
+ import ProductServices from "#osi/services/ProductServices";
2
+
3
+ export default function useLogic({ props }) {
4
+ const { s, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
5
+ const placeholders = usePlaceholders();
6
+ const { mediaUrl } = useMediaUrl();
7
+ const editing = computed(() => !!placeholders.allowed?.value);
8
+
9
+ /* ------------------------------------------------------------- the product */
10
+
11
+ const idOf = (v) => (v && typeof v === "object" ? v.id ?? null : v === "" || v == null ? null : v);
12
+ // The pick, wherever the payload carries it: the setting (an id, or the
13
+ // object the render endpoint may expand it into), or the resolved data.
14
+ const pickedId = computed(() => idOf(s.value.PRODUCT) ?? idOf(props.section.data?.product) ?? null);
15
+ // A product the render endpoint already resolved, used until our own fetch lands.
16
+ const preloaded = computed(() => {
17
+ const d = props.section.data ?? {};
18
+ const p = d.products?.[0] ?? (d.product && typeof d.product === "object" ? d.product : null);
19
+ return p?.name ? p : null;
20
+ });
21
+
22
+ /**
23
+ * The picked product, else the store's first one — on the live store too, not
24
+ * only in the editor, so what the merchant designed against is what shoppers
25
+ * see (Shopify fills an unpicked one the same way). Fetched in list shape (the
26
+ * card reads price ranges, variants, gallery).
27
+ */
28
+ const { data: fetched, refresh } = useAsyncData(
29
+ `osi-product-highlight-${props.section.id ?? props.section.key}`,
30
+ async () => {
31
+ if (pickedId.value != null) {
32
+ const res = await ProductServices.getProductsByIds([pickedId.value]).catch(() => null);
33
+ return (res?.results ?? res ?? [])[0] ?? null;
34
+ }
35
+ const res = await ProductServices.listProducts({ page_size: 1 }).catch(() => null);
36
+ return res?.results?.[0] ?? null;
37
+ },
38
+ { watch: [pickedId], default: () => null }
39
+ );
40
+ // A server render that couldn't reach the API leaves null in the payload,
41
+ // and useAsyncData won't fetch again on its own after hydration.
42
+ onMounted(() => {
43
+ if (!fetched.value) refresh();
44
+ });
45
+
46
+ const rows = computed(() => {
47
+ const product = fetched.value ?? preloaded.value;
48
+ return product ? [product] : placeholders.products([], 1);
49
+ });
50
+ const { items } = useProductItems(() => ({ settings: {} }), rows);
51
+ const item = computed(() => items.value[0] ?? null);
52
+
53
+ /* --------------------------------------------------------------- the blocks */
54
+
55
+ const PAD = Object.fromEntries(
56
+ ["TOP", "BOTTOM", "LEFT", "RIGHT"].flatMap((side) => [
57
+ [`PADDING_${side}`, 40],
58
+ [`PADDING_${side}_MOBILE`, 20],
59
+ ])
60
+ );
61
+ // The schema's default_blocks, for a section that has none.
62
+ const DEFAULT_BLOCKS = [
63
+ { key: "__media", type: "MEDIA", settings: { FIT: "COVER", RATIO: "PORTRAIT" } },
64
+ { key: "__card", type: "CARD", settings: { GAP: 8, HOVER: "NONE", ...PAD } },
65
+ { key: "__title", type: "CARD_TITLE", parent_key: "__card", settings: { PRESET: "CUSTOM", SIZE: 16, WEIGHT: "MEDIUM", LINES: 2 } },
66
+ { key: "__price", type: "CARD_PRICE", parent_key: "__card", settings: { PRESET: "CUSTOM", SIZE: 14, SHOW_COMPARE: true } },
67
+ { key: "__photo", type: "CARD_IMAGE", parent_key: "__card", settings: { RATIO: "NATURAL", SCREEN_HEIGHT: "CONTAIN" } },
68
+ { key: "__swatches", type: "CARD_SWATCHES", parent_key: "__card", settings: { SHOW: "COLOR" } },
69
+ ];
70
+
71
+ const effective = computed(() =>
72
+ (props.section.blocks ?? []).length ? props.section : { ...props.section, blocks: DEFAULT_BLOCKS }
73
+ );
74
+ // Root images fill their height: the media fills its half from tablets up.
75
+ const { tree } = useContentTree(effective, { fillRootImages: true, index: () => props.index });
76
+ const fallback = useContentTree(() => ({ settings: {}, blocks: DEFAULT_BLOCKS }));
77
+
78
+ const rawCard = computed(
79
+ () =>
80
+ tree.value.children.find((c) => c.type === "CARD") ??
81
+ fallback.tree.value.children.find((c) => c.type === "CARD")
82
+ );
83
+
84
+ /**
85
+ * The card as drawn: title and price moved into one row (price at the end,
86
+ * on the title's baseline), where the first of them sits; the photo shows the
87
+ * first photo only (no second image on hover).
88
+ */
89
+ const cardNode = computed(() => {
90
+ const card = rawCard.value;
91
+ if (!card) return null;
92
+ const isRowPart = (c) => c.type === "CARD_TITLE" || c.type === "CARD_PRICE";
93
+ const rowParts = card.children.filter(isRowPart);
94
+ const children = [];
95
+ for (const child of card.children) {
96
+ if (isRowPart(child)) {
97
+ if (child === rowParts[0]) children.push(rowOf(card.key, rowParts));
98
+ continue;
99
+ }
100
+ children.push(child.type === "CARD_IMAGE" ? { ...child, hoverImage: false, wishlist: false } : child);
101
+ }
102
+ return { ...card, children };
103
+ });
104
+
105
+ const NO_PADDING = Object.fromEntries(
106
+ ["t", "r", "b", "l"].flatMap((k) => [[`--cb-p${k}`, "0px"], [`--cb-p${k}-m`, "0px"]])
107
+ );
108
+ function rowOf(cardKey, parts) {
109
+ return {
110
+ key: `${cardKey}~row`,
111
+ type: "GROUP",
112
+ virtual: true,
113
+ s: {},
114
+ children: parts,
115
+ flexClasses: ["is-row"],
116
+ flexVars: {
117
+ "--cb-gap": "12px",
118
+ "--cb-gap-m": "12px",
119
+ "--cb-row-justify": "space-between",
120
+ "--cb-row-align": "baseline",
121
+ "--cb-text-align": "start",
122
+ },
123
+ sizeClasses: ["w-fill", "osi-ph-row"],
124
+ sizeVars: {},
125
+ box: NO_PADDING,
126
+ bg: null,
127
+ overlay: null,
128
+ link: null,
129
+ clip: false,
130
+ };
131
+ }
132
+
133
+ const rawMedia = computed(() => (effective.value.blocks ?? []).find((b) => b.type === "MEDIA" && !b.parent_key) ?? null);
134
+ const showMedia = computed(() => !!rawMedia.value && !rawMedia.value.disabled);
135
+ const mediaNode = computed(() => tree.value.children.find((c) => c.type === "MEDIA") ?? null);
136
+
137
+ /** Nothing picked in the Media block: one of the product's own photos. */
138
+ const MEDIA_RATIOS = { PORTRAIT: "4 / 5", SQUARE: "1 / 1", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
139
+ const fallbackMedia = computed(() => {
140
+ if (mediaNode.value?.src) return null;
141
+ const v = rawMedia.value?.settings ?? {};
142
+ const first = item.value?.image || "";
143
+ const second = (item.value?.raw?.gallery ?? [])
144
+ .map((g) => (g?.media ? mediaUrl(g.media) : ""))
145
+ .find((src) => src && src !== first);
146
+ return {
147
+ key: rawMedia.value?.id ?? rawMedia.value?.key,
148
+ // The product's second photo (the card shows the first), else the first.
149
+ src: second || first,
150
+ style: {
151
+ "--ph-fb-ratio": MEDIA_RATIOS[String(v.RATIO || "PORTRAIT").toUpperCase()] ?? "4 / 5",
152
+ "--ph-fb-fit": String(v.FIT || "COVER").toUpperCase() === "CONTAIN" ? "contain" : "cover",
153
+ },
154
+ };
155
+ });
156
+
157
+ /* --------------------------------------------------- photo held to the screen */
158
+
159
+ const photosNode = computed(() => cardNode.value?.children.find((c) => c.type === "CARD_IMAGE") ?? null);
160
+ const SHAPES = { SQUARE: 1, PORTRAIT_4_5: 0.8, PORTRAIT_3_4: 0.75, LANDSCAPE: 4 / 3, WIDE: 16 / 9 };
161
+ const shape = computed(() => String(photosNode.value?.s?.RATIO || "NATURAL").toUpperCase());
162
+ const screen = computed(() => {
163
+ if (!photosNode.value || shape.value === "FIXED") return "OFF";
164
+ return String(photosNode.value.s?.SCREEN_HEIGHT || "OFF").toUpperCase();
165
+ });
166
+
167
+ // The image's own shape: the photo, measured once it loads (1 until then).
168
+ const measured = ref(null);
169
+ const firstPhoto = computed(() => item.value?.image || "");
170
+ watch(
171
+ firstPhoto,
172
+ (src) => {
173
+ measured.value = null;
174
+ if (!import.meta.client || !src) return;
175
+ const img = new Image();
176
+ img.onload = () => {
177
+ if (firstPhoto.value === src && img.naturalWidth && img.naturalHeight) {
178
+ measured.value = img.naturalWidth / img.naturalHeight;
179
+ }
180
+ };
181
+ img.src = src;
182
+ },
183
+ { immediate: true }
184
+ );
185
+ const ratio = computed(() => SHAPES[shape.value] ?? measured.value ?? 1);
186
+
187
+ const productVars = computed(() => {
188
+ if (screen.value === "CONTAIN") {
189
+ return { "--ph-ratio": String(ratio.value), "--cb-ratio-override": String(ratio.value) };
190
+ }
191
+ if (screen.value === "COVER") {
192
+ return { "--cb-ratio-override": "auto", "--cb-h-override": "var(--ph-screen-h)" };
193
+ }
194
+ return {};
195
+ });
196
+
197
+ const visible = computed(() => !!item.value || editing.value);
198
+ const mediaEnd = computed(() => String(s.value.MEDIA_POSITION || "START").toUpperCase() === "END");
199
+
200
+ return { s, shellStyle, innerStyle, customClass, placeholders, mediaUrl, editing, idOf, pickedId, preloaded, fetched, refresh, rows, items, item, PAD, DEFAULT_BLOCKS, effective, tree, fallback, rawCard, cardNode, NO_PADDING, rowOf, rawMedia, showMedia, mediaNode, MEDIA_RATIOS, fallbackMedia, photosNode, SHAPES, shape, screen, measured, firstPhoto, ratio, productVars, visible, mediaEnd };
201
+ }
@@ -0,0 +1,45 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
4
+ const { products, loading } = useSectionData(() => props.section);
5
+ const { items } = useProductItems(() => props.section, products);
6
+ const { tree } = useContentTree(() => props.section, {
7
+ rootDefaults: { DIRECTION: "VERTICAL", GAP: 24 },
8
+ index: () => props.index,
9
+ });
10
+
11
+ // No Card block (an older section, or the Card was deleted): a default
12
+ // product card, so the list never disappears.
13
+ const fallback = useContentTree(() => ({
14
+ settings: {},
15
+ blocks: [
16
+ { key: "__card", type: "CARD", settings: { GAP: 6, HOVER: "NONE" } },
17
+ { key: "__image", type: "CARD_IMAGE", parent_key: "__card", settings: { RATIO: "PORTRAIT_3_4", RADIUS: 12 } },
18
+ { key: "__title", type: "CARD_TITLE", parent_key: "__card", settings: { PRESET: "CUSTOM", SIZE: 15, WEIGHT: "MEDIUM", LINES: 2 } },
19
+ { key: "__price", type: "CARD_PRICE", parent_key: "__card", settings: { PRESET: "CUSTOM", SIZE: 14 } },
20
+ { key: "__button", type: "CARD_BUTTON", parent_key: "__card", settings: { ACTION: "QUICK_BUY", QUICK_BUY_STYLE: "SHEET", WIDTH: "FILL", LABEL: "Add to cart", PLACEMENT: "ON_IMAGE", PLACEMENT_MOBILE: "IN_CARD", IMAGE_POSITION: "BOTTOM", REVEAL: "HOVER" } },
21
+ ],
22
+ }));
23
+
24
+ const cardNode = computed(() => tree.value.children.find((n) => n.type === "CARD") ?? null);
25
+ const template = computed(() => cardNode.value ?? fallback.tree.value.children[0]);
26
+
27
+ // Older sections: their HEADING, until they have blocks of their own.
28
+ const legacyHeading = computed(() =>
29
+ (props.section.blocks ?? []).some((b) => b.type !== "PRODUCT") ? "" : t(s.value.HEADING) || ""
30
+ );
31
+
32
+ /** The page, in block order; the list goes where the Card is (else last). */
33
+ const parts = computed(() => {
34
+ const out = tree.value.children.map((node) =>
35
+ node.type === "CARD" ? { kind: "list", key: "list" } : { kind: "block", key: node.key, node }
36
+ );
37
+ if (!cardNode.value) out.push({ kind: "list", key: "list" });
38
+ return out;
39
+ });
40
+
41
+ const scopeAttr = computed(() => `[data-osi-section="${props.section.id}"]`);
42
+ useScopedCss(() => s.value.CUSTOM_CSS, scopeAttr, `osi-section-css-${props.section.id}`);
43
+
44
+ return { s, t, shellStyle, innerStyle, customClass, products, loading, items, tree, fallback, cardNode, template, legacyHeading, parts, scopeAttr };
45
+ }
@@ -0,0 +1,113 @@
1
+ import { useProductDetail, useProductResource } from "#osi/composables/useProductDetail";
2
+ import { useRelatedProducts } from "#osi/composables/useRelatedProducts";
3
+
4
+ export default function useLogic({ props }) {
5
+ const { s, t, shellStyle, innerStyle, align, customClass } = useSectionShell(
6
+ () => props.section
7
+ );
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
+ // Under MAIN_PRODUCT this is already resolved; standalone it resolves itself.
14
+ const detail = useProductDetail();
15
+ const fallback = detail ? null : useProductResource();
16
+ const product = computed(() => detail?.product.value ?? fallback?.product.value ?? null);
17
+
18
+ const limit = computed(() => Number(s.value.LIMIT) || 4);
19
+
20
+ /** Pinned picks: the PRODUCT blocks, in the merchant's order. */
21
+ const pinnedIds = computed(() =>
22
+ (props.section.blocks ?? [])
23
+ .filter((block) => !block.disabled && block.type === "PRODUCT")
24
+ .map((block) => {
25
+ const value = block.relations?.product ?? block.settings?.PRODUCT ?? null;
26
+ return value && typeof value === "object" ? value.id : value;
27
+ })
28
+ .filter(Boolean)
29
+ );
30
+
31
+ const { products } = useRelatedProducts({
32
+ product,
33
+ limit,
34
+ preloaded: () => props.section.data?.products ?? [],
35
+ settings: () => s.value,
36
+ pinnedIds,
37
+ });
38
+
39
+ const heading = computed(() => t(s.value.HEADING) || "You may also like");
40
+
41
+ const analytics = useAnalytics();
42
+ if (import.meta.client) {
43
+ watch(
44
+ products,
45
+ (list) => {
46
+ if (!list?.length) return;
47
+ analytics.viewItemList({
48
+ id: `related:${props.section.key ?? props.section.id ?? "recommendations"}`,
49
+ name: heading.value,
50
+ products: list,
51
+ });
52
+ },
53
+ { immediate: true, flush: "post" }
54
+ );
55
+ }
56
+ const isCarousel = computed(() => s.value.LAYOUT === "CAROUSEL");
57
+ const columns = computed(() => Number(s.value.COLUMNS) || 4);
58
+ const perView = computed(() => Number(s.value.SLIDES_PER_VIEW) || 4);
59
+ const cardSettings = computed(() => {
60
+ const out = { ...(s.value.CARD_OVERRIDES ?? {}) };
61
+ for (const key of ["CARD_IMAGE_RATIO", "CARD_TEXT_ALIGN", "CARD_TEXT_POSITION", "CARD_CTA_STYLE"]) {
62
+ if (s.value[key]) out[key] = s.value[key];
63
+ }
64
+ // The badges' look (CARD_BADGE_* / CARD_SALE_BADGE_* / CARD_SOLD_OUT_BADGE_*). 0 is a real value.
65
+ for (const [key, value] of Object.entries(s.value)) {
66
+ if (/^CARD_(SALE_|SOLD_OUT_)?BADGE_/.test(key) && value !== undefined && value !== null && value !== "") out[key] = value;
67
+ }
68
+ return out;
69
+ });
70
+
71
+ const headingStyle = useTextStyle(() => s.value, {
72
+ prefix: "HEADING_TEXT",
73
+ defaults: { size: 22, weight: "REGULAR", font: "HEADING", lineHeight: 120 },
74
+ });
75
+
76
+ const gridStyle = computed(() =>
77
+ compact({
78
+ "--rec-cols": columns.value,
79
+ "--rec-cols-mobile": Number(s.value.COLUMNS_MOBILE) || 2,
80
+ "--rec-gap-x": px(s.value.GRID_GAP_X),
81
+ "--rec-gap-y": px(s.value.GRID_GAP_Y),
82
+ })
83
+ );
84
+
85
+ const viewAll = computed(() => {
86
+ if (!s.value.SHOW_VIEW_ALL) return null;
87
+ const url = s.value.VIEW_ALL_URL || "/collections/all";
88
+ return { label: t(s.value.VIEW_ALL_LABEL) || "View all", url };
89
+ });
90
+
91
+ const { slides, onSwiper, onSlideChange, goPrev, goNext, isBeginning, isEnd } = useCarousel({
92
+ items: () => products.value,
93
+ loop: false,
94
+ });
95
+
96
+ const showArrows = computed(
97
+ () => s.value.SHOW_ARROWS !== false && products.value.length > perView.value
98
+ );
99
+
100
+ const breakpoints = computed(() => ({
101
+ 0: { slidesPerView: Number(s.value.SLIDES_PER_VIEW_MOBILE) || 1.4, spaceBetween: 12 },
102
+ 640: { slidesPerView: 2.2, spaceBetween: 16 },
103
+ 1024: { slidesPerView: perView.value, spaceBetween: 16 },
104
+ }));
105
+
106
+ useScopedCss(
107
+ () => s.value.CUSTOM_CSS,
108
+ computed(() => `[data-osi-section="${props.section.id}"]`),
109
+ `osi-section-css-${props.section.id}`
110
+ );
111
+
112
+ return { s, t, shellStyle, innerStyle, align, customClass, has, px, compact, detail, fallback, product, limit, pinnedIds, products, heading, analytics, isCarousel, columns, perView, cardSettings, headingStyle, gridStyle, viewAll, slides, onSwiper, onSlideChange, goPrev, goNext, isBeginning, isEnd, showArrows, breakpoints };
113
+ }
@@ -0,0 +1,6 @@
1
+
2
+ export default function useLogic() {
3
+ const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "CENTER", GAP: 16 };
4
+
5
+ return { ROOT_DEFAULTS };
6
+ }
@@ -0,0 +1,69 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
4
+ // imageHeight: an image in the strip is sized by height (the width follows
5
+ // the image) — 36px unless the block sets its own.
6
+ const { tree, editing, u } = useContentTree(() => props.section, {
7
+ index: () => props.index,
8
+ imageHeight: 36,
9
+ });
10
+ const { num, px, upper } = u;
11
+
12
+ const items = computed(() => tree.value.children);
13
+
14
+ const SEPARATORS = { DOT: "•", STAR: "✦", SLASH: "/", DIAMOND: "◆" };
15
+ const separator = computed(() => {
16
+ const kind = upper(s.value.SEPARATOR, "DOT");
17
+ if (kind === "NONE") return "";
18
+ if (kind === "CUSTOM") return t(s.value.SEPARATOR_TEXT) || "";
19
+ return SEPARATORS[kind] ?? "•";
20
+ });
21
+
22
+ /** One copy long enough to fill a wide screen, then that copy twice. */
23
+ const track = computed(() => {
24
+ const base = items.value;
25
+ if (!base.length) return [];
26
+ const repeat = Math.max(1, Math.ceil(10 / base.length));
27
+ const one = Array.from({ length: repeat }, (_, r) =>
28
+ base.map((node) => ({ node, key: `${node.key}-r${r}` }))
29
+ ).flat();
30
+ return [
31
+ ...one.map((i) => ({ ...i, key: `${i.key}-a`, clone: false })),
32
+ ...one.map((i) => ({ ...i, key: `${i.key}-b`, clone: true })),
33
+ ];
34
+ });
35
+
36
+ const ALIGN = { TOP: "flex-start", CENTER: "center", BOTTOM: "flex-end" };
37
+
38
+ const vars = computed(() => {
39
+ const v = s.value;
40
+ const count = Math.max(1, track.value.length / 2);
41
+ return {
42
+ "--mq-duration": `${count * num(v.MARQUEE_SPEED, 3)}s`,
43
+ "--mq-gap": px(v.ITEM_GAP) ?? "2.5rem",
44
+ "--mq-gap-mobile": px(v.ITEM_GAP_MOBILE) ?? px(v.ITEM_GAP) ?? "1.5rem",
45
+ "--mq-align": ALIGN[upper(v.ITEMS_ALIGN, "CENTER")] ?? "center",
46
+ "--mq-border": v.BORDER_COLOR || "var(--osi-border, #e5e5e5)",
47
+ "--mq-sep-color": v.SEPARATOR_COLOR || "currentColor",
48
+ "--mq-sep-size": px(v.SEPARATOR_SIZE) ?? "16px",
49
+ };
50
+ });
51
+
52
+ const fullWidth = computed(() => s.value.FULL_WIDTH !== false);
53
+
54
+ const rootClasses = computed(() => ({
55
+ "osi-mq--reverse": upper(s.value.MARQUEE_DIRECTION, "LEFT") === "RIGHT",
56
+ "osi-mq--pause": s.value.MARQUEE_PAUSE_ON_HOVER !== false,
57
+ "osi-mq--border-top": !!s.value.BORDER_TOP,
58
+ "osi-mq--border-bottom": !!s.value.BORDER_BOTTOM,
59
+ }));
60
+
61
+ const containerStyle = computed(() =>
62
+ fullWidth.value ? { maxWidth: "100%", marginInline: 0 } : innerStyle.value
63
+ );
64
+ const sectionStyle = computed(() =>
65
+ fullWidth.value ? { ...shellStyle.value, paddingLeft: 0, paddingRight: 0 } : shellStyle.value
66
+ );
67
+
68
+ return { s, t, shellStyle, innerStyle, customClass, tree, editing, u, num, px, upper, items, SEPARATORS, separator, track, ALIGN, vars, fullWidth, rootClasses, containerStyle, sectionStyle };
69
+ }
@@ -0,0 +1,22 @@
1
+ import { ClassificationPart } from "#components";
2
+
3
+ export default function useLogic() {
4
+ const route = useRoute();
5
+ const terms = computed(() => String(route.query.q ?? "").trim());
6
+
7
+ provide("osiPlp", {
8
+ kind: computed(() => "SEARCH"),
9
+ terms,
10
+ title: terms,
11
+ description: computed(() => ""),
12
+ image: computed(() => ""),
13
+ breadcrumbs: computed(() => []),
14
+ navItems: computed(() => []),
15
+ });
16
+
17
+ const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "CENTER", GAP: 16 };
18
+ const CUSTOM = { SEARCH_TITLE: ClassificationPart, SEARCH_INPUT: ClassificationPart };
19
+ const CUSTOM_TEXT = { SEARCH_TITLE: "H2" };
20
+
21
+ return { route, terms, ROOT_DEFAULTS, CUSTOM, CUSTOM_TEXT };
22
+ }