@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,770 @@
1
+ import { markRaw } from "vue";
2
+ /**
3
+ * The block tree of a block-built section (MEDIA_WITH_TEXT, VIDEO, …).
4
+ *
5
+ * Blocks nest: block.parent_key names the GROUP a block sits in ('' = the
6
+ * section). Order is the section's flat block order. This turns the flat list
7
+ * into resolved, pruned nodes; ContentNode.vue draws them. The root node is a
8
+ * flex container laid out by the section's own DIRECTION / VERTICAL_ON_MOBILE /
9
+ * ALIGNMENT / POSITION / GAP settings.
10
+ *
11
+ * Pruned (never rendered): disabled blocks and their subtrees, blocks whose
12
+ * group isn't in the payload, empty text, a button without label or link, an
13
+ * image without media, a group left with no children and no background media.
14
+ * In the builder editor, images and groups are kept with placeholders so there
15
+ * is something to click.
16
+ *
17
+ * @param section getter / ref of the section
18
+ * @param options rootDefaults settings the root falls back to (e.g. its
19
+ * DIRECTION when the section never set one)
20
+ * fillRootImages getter: images directly in the section fill
21
+ * its height instead of setting it
22
+ * index getter: the section's index (first image on
23
+ * the first section loads eagerly)
24
+ * imageHeight px an Image block falls back to when it sets
25
+ * no IMAGE_HEIGHT of its own
26
+ *
27
+ * Returns { tree, backgroundOf, editing, u } — `u` holds the small value
28
+ * helpers so sections using this don't redefine them.
29
+ */
30
+ /**
31
+ * options.custom { BLOCK_TYPE: Component } — block types a section draws
32
+ * with components of its own (see the default case below).
33
+ * options.customText { BLOCK_TYPE: default preset } — which of those take the
34
+ * Text block's look (preset, size, colour…).
35
+ * options.raw [BLOCK_TYPE] — kept as plain nodes (with their children)
36
+ * for the section to draw itself.
37
+ */
38
+ export function useContentTree(section, options = {}) {
39
+ const t = useT();
40
+ const s = computed(() => toValue(section)?.settings ?? {});
41
+ const { mediaUrl } = useMediaUrl();
42
+ const { isVideo } = useMediaTypes();
43
+ const { isMobile } = useLayoutShell();
44
+ const placeholders = usePlaceholders();
45
+ const editing = computed(() => !!placeholders.allowed?.value);
46
+
47
+ const has = (v) => v !== undefined && v !== null && v !== "";
48
+ const num = (v, d) => (has(v) && Number.isFinite(Number(v)) ? Number(v) : d);
49
+ const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
50
+ const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => has(v)));
51
+ const upper = (v, d) => String(v || d).toUpperCase();
52
+ const pathOf = (m) => m?.path ?? (typeof m === "string" ? m : "");
53
+
54
+ /* ------------------------------------------------------------ shared bits */
55
+
56
+ const ROW_JUSTIFY = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end", SPACE_BETWEEN: "space-between" };
57
+ const COL_ALIGN = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end", SPACE_BETWEEN: "flex-start" };
58
+ const CROSS = { TOP: "flex-start", CENTER: "center", BOTTOM: "flex-end" };
59
+ const TEXT_ALIGN = { LEFT: "start", CENTER: "center", RIGHT: "end", SPACE_BETWEEN: "start" };
60
+
61
+ /** Container classes + vars from DIRECTION / ALIGNMENT / POSITION / GAP. */
62
+ function flexOf(v) {
63
+ const row = upper(v.DIRECTION, "VERTICAL") === "HORIZONTAL";
64
+ // SPACE_BETWEEN is a row-only choice (the panel only offers it then); a
65
+ // column that still has it stored lines up left, as the panel shows.
66
+ const chosen = upper(v.ALIGNMENT, "LEFT");
67
+ const align = !row && chosen === "SPACE_BETWEEN" ? "LEFT" : chosen;
68
+ const position = upper(v.POSITION, "CENTER");
69
+ // CAROUSEL (horizontal only): MOBILE swipes on phones and is a normal row
70
+ // above; ALWAYS swipes everywhere. Either one replaces stacking on phones.
71
+ const mode = row ? upper(v.CAROUSEL, "NONE") : "NONE";
72
+ const carousel = mode === "MOBILE" || mode === "ALWAYS";
73
+ return {
74
+ flexClasses: [
75
+ row ? "is-row" : "is-col",
76
+ {
77
+ // WRAP: a row that continues on the next line instead of stacking.
78
+ "is-wrap": row && !carousel && !!v.WRAP,
79
+ "stack-mobile": row && !carousel && !v.WRAP && v.VERTICAL_ON_MOBILE !== false,
80
+ "car-mobile": mode === "MOBILE",
81
+ "car-always": mode === "ALWAYS",
82
+ },
83
+ ],
84
+ carousel: carousel
85
+ ? {
86
+ arrows: v.CAROUSEL_ARROWS !== false,
87
+ dots: v.CAROUSEL_DOTS !== false,
88
+ autoplay: !!v.CAROUSEL_AUTOPLAY,
89
+ interval: num(v.CAROUSEL_INTERVAL, 5) * 1000,
90
+ loop: v.CAROUSEL_LOOP !== false,
91
+ // Where the controls sit, and how they look.
92
+ arrowsAt: upper(v.CAROUSEL_ARROWS_POSITION, "BELOW") === "SIDES" ? "SIDES" : "BELOW",
93
+ dotsAt: upper(v.CAROUSEL_DOTS_POSITION, "BELOW") === "INSIDE" ? "INSIDE" : "BELOW",
94
+ dotStyle: ["BAR", "LINE"].includes(upper(v.CAROUSEL_DOT_STYLE, "DOT"))
95
+ ? upper(v.CAROUSEL_DOT_STYLE, "DOT")
96
+ : "DOT",
97
+ arrowBorder: v.CAROUSEL_ARROW_BORDER !== false,
98
+ controlVars: compact({
99
+ "--bc-arrow": px(v.CAROUSEL_ARROW_SIZE),
100
+ "--bc-arrow-color": v.CAROUSEL_ARROW_COLOR,
101
+ "--bc-arrow-bg": v.CAROUSEL_ARROW_BG,
102
+ "--bc-arrow-border": v.CAROUSEL_ARROW_BORDER_COLOR,
103
+ "--bc-arrow-radius": px(v.CAROUSEL_ARROW_RADIUS),
104
+ "--bc-arrow-offset": px(v.CAROUSEL_ARROW_OFFSET),
105
+ "--bc-dot": px(v.CAROUSEL_DOT_SIZE),
106
+ "--bc-dot-color": v.CAROUSEL_DOT_COLOR,
107
+ "--bc-dot-active": v.CAROUSEL_DOT_ACTIVE_COLOR,
108
+ "--bc-controls-gap": px(v.CAROUSEL_CONTROLS_SPACING),
109
+ }),
110
+ }
111
+ : null,
112
+ flexVars: compact({
113
+ "--cb-per": carousel ? String(num(v.PER_VIEW, 3)) : undefined,
114
+ "--cb-per-m": carousel ? String(num(v.PER_VIEW_MOBILE, 1.2)) : undefined,
115
+ // Both always set — a nested group must not inherit its parent's gap.
116
+ "--cb-gap": `${num(v.GAP, 16)}px`,
117
+ "--cb-gap-m": `${num(v.GAP_MOBILE, num(v.GAP, 16))}px`,
118
+ "--cb-row-justify": ROW_JUSTIFY[align],
119
+ "--cb-row-align": CROSS[position],
120
+ "--cb-col-align": COL_ALIGN[align],
121
+ "--cb-col-justify": CROSS[position],
122
+ "--cb-text-align": TEXT_ALIGN[align],
123
+ }),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Width classes. Desktop from `id` (FIT / FILL / CUSTOM + `${id}_CUSTOM` %);
129
+ * phones from `mobileId` when the block has one, else the desktop mode
130
+ * (a custom width becomes fill once a row stacks).
131
+ */
132
+ // Width shares are shortcuts for a custom %.
133
+ const SHARES = { QUARTER: 25, THIRD: 100 / 3, HALF: 50, TWO_THIRDS: 200 / 3, THREE_QUARTERS: 75 };
134
+
135
+ /** FIT / FILL as is; a share or CUSTOM → { mode: "CUSTOM", pct }. */
136
+ function widthOf(v, id, fallback) {
137
+ const mode = upper(v[id], fallback);
138
+ if (SHARES[mode]) return { mode: "CUSTOM", pct: SHARES[mode] };
139
+ if (mode === "CUSTOM") return { mode, pct: num(v[`${id}_CUSTOM`], 50) };
140
+ return { mode: mode === "FIT" ? "FIT" : "FILL" };
141
+ }
142
+
143
+ function sizeOf(v, id, mobileId, fallback = "FILL") {
144
+ const desk = widthOf(v, id, fallback);
145
+ // No mobile setting: a custom / share width becomes fill once a row stacks.
146
+ const mob = mobileId ? widthOf(v, mobileId, fallback) : { mode: desk.mode === "CUSTOM" ? "FILL" : desk.mode };
147
+ return {
148
+ sizeClasses: [`w-${desk.mode.toLowerCase()}`, `mw-${mob.mode.toLowerCase()}`],
149
+ sizeVars: compact({
150
+ "--cb-w": desk.pct != null ? `${desk.pct}%` : undefined,
151
+ "--cb-mw": mob.pct != null ? `${mob.pct}%` : undefined,
152
+ }),
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Padding as variables, desktop and phone, like the section's own
158
+ * (PADDING_* and PADDING_*_MOBILE; an empty phone value = the desktop one).
159
+ * ContentNode's CSS picks the pair per breakpoint. All eight are always set:
160
+ * custom properties inherit, so a block that left one out would pick up its
161
+ * group's padding.
162
+ */
163
+ const SIDES = { TOP: "t", RIGHT: "r", BOTTOM: "b", LEFT: "l" };
164
+ function paddingOf(v) {
165
+ const out = {};
166
+ for (const [side, k] of Object.entries(SIDES)) {
167
+ const desktop = num(v[`PADDING_${side}`], 0);
168
+ out[`--cb-p${k}`] = `${desktop}px`;
169
+ out[`--cb-p${k}-m`] = `${num(v[`PADDING_${side}_MOBILE`], desktop)}px`;
170
+ }
171
+ return out;
172
+ }
173
+
174
+ /** Background, border, corner radius, padding. */
175
+ function boxOf(v, { background = true } = {}) {
176
+ return {
177
+ ...compact({
178
+ background: background ? v.BG_COLOR : undefined,
179
+ border: upper(v.BORDER_STYLE, "NONE") === "SOLID"
180
+ ? `${num(v.BORDER_WIDTH, 1)}px solid ${v.BORDER_COLOR || "currentColor"}`
181
+ : undefined,
182
+ borderRadius: num(v.RADIUS, 0) ? `${num(v.RADIUS, 0)}px` : undefined,
183
+ }),
184
+ ...paddingOf(v),
185
+ };
186
+ }
187
+
188
+ function backgroundOf(v) {
189
+ const kind = upper(v.BACKGROUND_TYPE, "NONE");
190
+ const image = (isMobile.value && v.BACKGROUND_MOBILE_IMAGE) || v.BACKGROUND_IMAGE;
191
+ const src = kind === "IMAGE" ? mediaUrl(image ?? null) : kind === "VIDEO" ? mediaUrl(v.BACKGROUND_VIDEO_MEDIA ?? null) : "";
192
+ const opacity = num(v.OVERLAY_OPACITY, 0);
193
+ return {
194
+ bg: src ? { kind: kind === "VIDEO" ? "video" : "image", src } : null,
195
+ overlay: opacity > 0 ? { background: v.OVERLAY_COLOR || "#000", opacity: opacity / 100 } : null,
196
+ };
197
+ }
198
+
199
+ const RATIOS = { PORTRAIT: "4 / 5", SQUARE: "1 / 1", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
200
+ const MAX_WIDTHS = { NARROW: "28rem", NORMAL: "42rem", NONE: "none" };
201
+ const WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
202
+ const CASES = { NONE: "none", UPPERCASE: "uppercase", LOWERCASE: "lowercase", CAPITALIZE: "capitalize" };
203
+ const CARD_RATIOS = { SQUARE: "1 / 1", PORTRAIT_4_5: "4 / 5", PORTRAIT_3_4: "3 / 4", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
204
+
205
+ /**
206
+ * A Text block's look — element, preset classes, custom typography — for
207
+ * Text itself and for text-like card parts (title, count, description).
208
+ */
209
+ function textLook(v, defaultPreset = "DEFAULT") {
210
+ const preset = upper(v.PRESET, defaultPreset);
211
+ const heading = HEADINGS.includes(preset);
212
+ // The element: a heading preset is its own tag; CUSTOM uses TAG.
213
+ const customTag = preset === "CUSTOM" ? upper(v.TAG, "P") : "";
214
+ const tag = heading ? preset : HEADINGS.includes(customTag) ? customTag : "";
215
+ return {
216
+ tag: tag ? tag.toLowerCase() : "div",
217
+ presetClass: heading ? ["p-heading", `p-${preset.toLowerCase()}`] : [`p-${preset.toLowerCase()}`],
218
+ typeVars: compact({
219
+ "--cb-max": MAX_WIDTHS[upper(v.MAX_WIDTH, "NORMAL")],
220
+ "--cb-align": TEXT_ALIGN[upper(v.ALIGNMENT, "")],
221
+ "--cb-color": v.COLOR,
222
+ ...(preset === "CUSTOM"
223
+ ? {
224
+ "--cb-ff": v.FONT === "HEADING" ? "var(--osi-font-heading)" : undefined,
225
+ "--cb-fs": px(v.SIZE),
226
+ "--cb-fs-m": px(v.SIZE_MOBILE),
227
+ "--cb-fw": Number(v.WEIGHT) || WEIGHTS[upper(v.WEIGHT, "")],
228
+ "--cb-lh": has(v.LINE_HEIGHT) ? String(num(v.LINE_HEIGHT, 150) / 100) : undefined,
229
+ "--cb-ls": has(v.LETTER_SPACING) ? `${num(v.LETTER_SPACING, 0) / 100}em` : undefined,
230
+ "--cb-tt": CASES[v.CASE],
231
+ }
232
+ : {}),
233
+ }),
234
+ };
235
+ }
236
+
237
+ const SPACES = { SMALL: 12, MEDIUM: 24, LARGE: 48 };
238
+ const HEADINGS = ["H1", "H2", "H3", "H4", "H5", "H6"];
239
+
240
+ const plain = (html) => String(html || "").replace(/<(img|video|iframe)\b/gi, "x").replace(/<[^>]*>/g, "").replace(/&nbsp;/g, " ").trim();
241
+ // A heading preset renders as <hN>: its richtext paragraphs become lines.
242
+ const inline = (html) =>
243
+ String(html).replace(/<\/p>\s*<p[^>]*>/gi, "<br>").replace(/^\s*<p[^>]*>/i, "").replace(/<\/p>\s*$/i, "");
244
+
245
+ /* ------------------------------------------------------------ the tree */
246
+
247
+ let firstImage = true;
248
+
249
+ /** Block types that hold other blocks (their children point at them). */
250
+ // PRODUCT_GRID: the classification page's grid, holding its Card template.
251
+ const CONTAINERS = new Set(["GROUP", "ACCORDION", "SLIDE", "CARD", "PRODUCT_GRID"]);
252
+
253
+ /** Root-level images fill the container's height (a sized section). */
254
+ const sized = computed(() => !!toValue(options.fillRootImages));
255
+
256
+ function resolve(block, children, atRoot = false) {
257
+ const v = block.settings ?? {};
258
+ const base = { key: block.id ?? block.key, type: block.type, s: v, children };
259
+
260
+ switch (block.type) {
261
+ // A Slide is a Group with a background — drawn as one (type GROUP), marked
262
+ // `slide` so the slideshow can find it.
263
+ case "SLIDE":
264
+ case "GROUP": {
265
+ // An empty group is dropped; an empty slide still shows its background.
266
+ if (!children.length && !editing.value && block.type !== "SLIDE" && upper(v.BACKGROUND_TYPE, "NONE") === "NONE") return null;
267
+ const heightMode = upper(v.HEIGHT, "FIT");
268
+ const size = sizeOf(v, "WIDTH");
269
+ const nestedLink = children.some(function linked(c) {
270
+ return c.link || c.type === "BUTTON" || /<a\b/i.test(c.html || "") || c.children?.some(linked);
271
+ });
272
+ const background = backgroundOf(v);
273
+ return {
274
+ ...base,
275
+ type: "GROUP",
276
+ slide: block.type === "SLIDE",
277
+ ...flexOf(v),
278
+ ...background,
279
+ clip: !!(num(v.RADIUS, 0) || background.bg),
280
+ sizeClasses: [...size.sizeClasses, `h-${heightMode.toLowerCase()}`, { "is-sticky": !!v.STICKY }],
281
+ sizeVars: {
282
+ ...size.sizeVars,
283
+ ...(heightMode === "CUSTOM" ? { "--cb-h": `${num(v.HEIGHT_CUSTOM, 300)}px` } : {}),
284
+ // Phones: HEIGHT_MOBILE, else the custom height. Always set, so a
285
+ // nested group never inherits its parent's.
286
+ "--cb-h-m": `${num(v.HEIGHT_MOBILE, heightMode === "CUSTOM" ? num(v.HEIGHT_CUSTOM, 300) : 0)}px`,
287
+ },
288
+ // COLOR: text colour for everything inside (inherited).
289
+ box: { ...boxOf(v), ...compact({ color: v.COLOR }) },
290
+ link: v.LINK_URL && !nestedLink ? v.LINK_URL : null,
291
+ };
292
+ }
293
+
294
+ case "MEDIA": {
295
+ // In a section with a set height, an image sitting directly in the
296
+ // section fills that height (cropped) instead of forcing its own —
297
+ // otherwise its aspect ratio decides the height and the setting does
298
+ // nothing.
299
+ const media = isMobile.value ? v.MOBILE_MEDIA || v.MEDIA || block.media : v.MEDIA ?? block.media ?? block.relations?.media;
300
+ const src = mediaUrl(media ?? null);
301
+ if (!src && !editing.value) return null;
302
+ const ratio = upper(v.RATIO, "SQUARE");
303
+ // A section can give images a height to fall back on (the marquee: an
304
+ // item has no row to take a width from, so it's sized by height).
305
+ const imageHeight = has(v.IMAGE_HEIGHT) ? v.IMAGE_HEIGHT : toValue(options.imageHeight);
306
+ const eager = !!src && firstImage && toValue(options.index) === 0;
307
+ if (src) firstImage = false;
308
+ return {
309
+ ...base,
310
+ ...sizeOf(v, "DESKTOP_WIDTH", "MOBILE_WIDTH"),
311
+ box: boxOf(v, { background: false }),
312
+ frame: compact({
313
+ "--cb-ratio": RATIOS[ratio],
314
+ "--cb-fit": upper(v.FIT, "COVER").toLowerCase(),
315
+ "--cb-max-h": px(v.MAX_HEIGHT),
316
+ "--cb-max-h-m": px(v.MAX_HEIGHT_MOBILE),
317
+ "--cb-img-h": px(imageHeight),
318
+ "--cb-img-h-m": px(v.IMAGE_HEIGHT_MOBILE),
319
+ borderRadius: px(v.RADIUS),
320
+ }),
321
+ fillHeight: atRoot && sized.value && !has(imageHeight),
322
+ fixedHeight: has(imageHeight),
323
+ grayscale: !!v.GRAYSCALE,
324
+ ratio: ratio === "AUTO" ? "auto" : ratio,
325
+ src,
326
+ isVideo: !!src && isVideo(pathOf(media)),
327
+ autoplay: v.VIDEO_AUTOPLAY !== false,
328
+ controls: !!v.VIDEO_CONTROLS,
329
+ alt: t(v.ALT) || "",
330
+ link: v.LINK_URL || null,
331
+ eager,
332
+ };
333
+ }
334
+
335
+ case "TEXT": {
336
+ const html = t(v.TEXT);
337
+ if (!plain(html)) return null;
338
+ const look = textLook(v);
339
+ return {
340
+ ...base,
341
+ ...sizeOf(v, "WIDTH", null, "FIT"),
342
+ box: boxOf(v),
343
+ ...look,
344
+ // In an <hN>, richtext paragraphs become lines (no <p> in a heading).
345
+ html: look.tag !== "div" ? inline(html) : html,
346
+ };
347
+ }
348
+
349
+ /* ---- CLASSIFICATION_LIST's / PRODUCT_LIST's card template: drawn once per item, filled
350
+ in from it by ContentNode (its `item` prop). Never pruned. ---- */
351
+
352
+ case "CARD": {
353
+ const placement = upper(v.TEXT_PLACEMENT, "BELOW") === "ON_IMAGE" ? "on-image" : "below";
354
+ let kids = children;
355
+ let box = { ...boxOf(v), ...compact({ color: v.COLOR }) };
356
+ // Buy buttons set to "On the image" draw inside the image (its
357
+ // `overlays`), not in the card's flow. No image: they stay in flow.
358
+ // Desktop and phones can differ: a button on the image on one and in
359
+ // the card on the other is drawn in both spots, each shown only on
360
+ // its device (`showOn`).
361
+ const cardImage = kids.find((c) => c.type === "CARD_IMAGE");
362
+ if (placement === "below" && cardImage) {
363
+ const overlays = [];
364
+ const flow = [];
365
+ for (const c of kids) {
366
+ const desk = !!c.onImage;
367
+ const phone = c.onImageMobile ?? desk;
368
+ if (!desk && !phone) flow.push(c);
369
+ else if (desk && phone) overlays.push(c);
370
+ else {
371
+ overlays.push({ ...c, showOn: desk ? "desktop" : "mobile" });
372
+ flow.push({ ...c, showOn: desk ? "mobile" : "desktop" });
373
+ }
374
+ }
375
+ if (overlays.length) {
376
+ kids = flow.map((c) => (c === cardImage ? { ...c, overlays } : c));
377
+ }
378
+ }
379
+ if (placement === "on-image") {
380
+ // Image underneath, everything else in one layer over it, placed by
381
+ // TEXT_POSITION; the card's padding moves onto that layer.
382
+ const image = children.find((c) => c.type === "CARD_IMAGE");
383
+ const rest = children.filter((c) => c !== image);
384
+ const layer = {
385
+ key: `${base.key}~on-image`,
386
+ type: "GROUP",
387
+ virtual: true,
388
+ s: {},
389
+ children: rest,
390
+ ...flexOf({ DIRECTION: "VERTICAL", ALIGNMENT: v.ALIGNMENT, POSITION: v.TEXT_POSITION || "BOTTOM", GAP: v.GAP }),
391
+ sizeClasses: ["w-fill", "osi-cb-card-layer"],
392
+ sizeVars: {},
393
+ box: paddingOf(v),
394
+ bg: null,
395
+ overlay: null,
396
+ link: null,
397
+ clip: false,
398
+ };
399
+ kids = image ? [image, layer] : [layer];
400
+ box = { ...box, ...paddingOf({}) };
401
+ }
402
+ // Image to the card's edges (IMAGE_EDGE, off by default): the image
403
+ // cancels the card's padding with negative margins, so the padding
404
+ // goes around the text only. Needs the padding under names the image
405
+ // doesn't reset (--card-*), since its own --cb-p* are zero.
406
+ const bleed = placement === "below" && !!cardImage && v.IMAGE_EDGE === true;
407
+ // ALIGN_BUTTONS (on by default): the card fills its cell — every card
408
+ // in a row as tall as the tallest — and the first part holding a
409
+ // button is pushed to the bottom, taking what follows with it. So a
410
+ // one-line title and a two-line one still put their buttons on one
411
+ // line. Off for a lone card (Product highlight).
412
+ const alignButtons =
413
+ placement === "below" &&
414
+ v.ALIGN_BUTTONS !== false &&
415
+ toValue(section)?.type !== "PRODUCT_HIGHLIGHT";
416
+ if (alignButtons) {
417
+ const isButton = (c) => c.type === "CARD_BUTTON" || c.type === "BUTTON";
418
+ const holdsButton = (c) => isButton(c) || (c.children ?? []).some(holdsButton);
419
+ const at = kids.findIndex((c) => c.type !== "CARD_IMAGE" && holdsButton(c));
420
+ if (at >= 0) kids = kids.map((c, i) => (i === at ? { ...c, pushDown: true } : c));
421
+ }
422
+ if (bleed) {
423
+ for (const [k, val] of Object.entries(paddingOf(v))) box[k.replace("--cb-p", "--card-p")] = val;
424
+ }
425
+ return {
426
+ ...base,
427
+ ...flexOf(v),
428
+ children: kids,
429
+ sizeClasses: ["w-fill"],
430
+ sizeVars: {},
431
+ box,
432
+ cardClasses: [
433
+ ...(bleed ? ["image-bleed"] : []),
434
+ ...(alignButtons ? ["align-buttons"] : []),
435
+ `card-${placement}`,
436
+ `hover-${upper(v.HOVER, "ZOOM").toLowerCase()}`,
437
+ `shadow-${upper(v.SHADOW, "NONE").toLowerCase()}`,
438
+ ],
439
+ };
440
+ }
441
+
442
+ case "CARD_IMAGE": {
443
+ const ratio = upper(v.RATIO, "SQUARE");
444
+ const fixed = ratio === "FIXED";
445
+ const size = sizeOf(v, "WIDTH");
446
+ return {
447
+ ...base,
448
+ ...size,
449
+ // Its own (zero) padding: without it the image inherited the card's
450
+ // --cb-p* and padded itself inside its frame.
451
+ sizeVars: { ...size.sizeVars, ...paddingOf({}) },
452
+ natural: ratio === "NATURAL",
453
+ // Product cards only (PRODUCT_LIST's schema has these fields).
454
+ hoverImage: v.HOVER_IMAGE !== false,
455
+ badges: v.BADGES !== false,
456
+ badgePosition: upper(v.BADGE_POSITION, "TOP_LEFT").toLowerCase().replace("_", "-"),
457
+ saleLabel: t(v.SALE_LABEL) || "Sale",
458
+ soldOutLabel: t(v.SOLD_OUT_LABEL) || "Sold out",
459
+ wishlist: !!v.WISHLIST,
460
+ badgeBrand: !!v.BADGE_BRAND,
461
+ badgeCategories: !!v.BADGE_CATEGORIES,
462
+ // The brand / category badges' own corner; null: with the others.
463
+ badgeInfoPosition: upper(v.BADGE_INFO_POSITION, "SAME") === "SAME"
464
+ ? null
465
+ : upper(v.BADGE_INFO_POSITION, "SAME").toLowerCase().replace("_", "-"),
466
+ // The wishlist button's look (WISHLIST_*).
467
+ wish: {
468
+ icon: upper(v.WISHLIST_ICON, "HEART"),
469
+ svg: upper(v.WISHLIST_ICON, "HEART") === "CUSTOM" ? String(v.WISHLIST_ICON_SVG || "").trim() : "",
470
+ position: upper(v.WISHLIST_POSITION, "TOP_RIGHT").toLowerCase().replace("_", "-"),
471
+ plain: upper(v.WISHLIST_STYLE, "CIRCLE") === "PLAIN",
472
+ hover: upper(v.WISHLIST_REVEAL, "ALWAYS") === "HOVER",
473
+ vars: compact({
474
+ "--cb-wish-size": px(v.WISHLIST_SIZE),
475
+ "--cb-wish-icon": px(v.WISHLIST_ICON_SIZE),
476
+ "--cb-wish-offset": px(v.WISHLIST_OFFSET),
477
+ "--cb-wish-bg": v.WISHLIST_BG,
478
+ "--cb-wish-border": v.WISHLIST_BORDER_COLOR,
479
+ "--cb-wish-color": v.WISHLIST_COLOR,
480
+ "--cb-wish-active": v.WISHLIST_ACTIVE_COLOR,
481
+ }),
482
+ },
483
+ // The badge's own look (BADGE_* / SALE_BADGE_*); empty = the scheme's.
484
+ badgeVars: compact({
485
+ "--cb-badge-bg": v.BADGE_BG,
486
+ "--cb-badge-color": v.BADGE_TEXT_COLOR,
487
+ "--cb-badge-border": v.BADGE_BORDER_COLOR,
488
+ "--cb-badge-bw": px(v.BADGE_BORDER_WIDTH),
489
+ "--cb-badge-r": px(v.BADGE_RADIUS),
490
+ "--cb-badge-fs": px(v.BADGE_FONT_SIZE),
491
+ "--cb-badge-tt": has(v.BADGE_CASE) ? (upper(v.BADGE_CASE, "UPPERCASE") === "NONE" ? "none" : "uppercase") : undefined,
492
+ "--cb-badge-sale-bg": v.SALE_BADGE_BG,
493
+ "--cb-badge-sale-color": v.SALE_BADGE_TEXT_COLOR,
494
+ "--cb-badge-sale-border": v.SALE_BADGE_BORDER_COLOR,
495
+ "--cb-badge-out-bg": v.SOLD_OUT_BADGE_BG,
496
+ "--cb-badge-out-color": v.SOLD_OUT_BADGE_TEXT_COLOR,
497
+ "--cb-badge-out-border": v.SOLD_OUT_BADGE_BORDER_COLOR,
498
+ }),
499
+ frame: compact({
500
+ "--cb-ratio": fixed || ratio === "NATURAL" ? "auto" : CARD_RATIOS[ratio] ?? "1 / 1",
501
+ "--cb-h": fixed ? px(num(v.IMAGE_HEIGHT, 320)) : undefined,
502
+ "--cb-h-m": fixed ? px(num(v.IMAGE_HEIGHT_MOBILE, num(v.IMAGE_HEIGHT, 320))) : undefined,
503
+ "--cb-fit": upper(v.FIT, "COVER") === "CONTAIN" ? "contain" : "cover",
504
+ borderRadius: px(v.RADIUS),
505
+ }),
506
+ overlay:
507
+ num(v.OVERLAY_OPACITY, 0) > 0
508
+ ? { background: v.OVERLAY_COLOR || "#000", opacity: num(v.OVERLAY_OPACITY, 0) / 100 }
509
+ : null,
510
+ };
511
+ }
512
+
513
+ case "CARD_TITLE":
514
+ case "CARD_COUNT":
515
+ case "CARD_DESCRIPTION":
516
+ return {
517
+ ...base,
518
+ ...sizeOf(v, "WIDTH", null, "FIT"),
519
+ box: boxOf(v),
520
+ ...textLook(v, { CARD_TITLE: "H4", CARD_COUNT: "CUSTOM", CARD_DESCRIPTION: "PARAGRAPH" }[block.type]),
521
+ part: { CARD_TITLE: "title", CARD_COUNT: "count", CARD_DESCRIPTION: "description" }[block.type],
522
+ prefix: t(v.PREFIX),
523
+ singular: t(v.SINGULAR) || "product",
524
+ plural: t(v.PLURAL) || "products",
525
+ lines: num(v.LINES, 2),
526
+ // Product titles clamp only when the block sets LINES.
527
+ clamp: block.type === "CARD_DESCRIPTION" || has(v.LINES),
528
+ };
529
+
530
+ case "CARD_PRICE":
531
+ return {
532
+ ...base,
533
+ ...sizeOf(v, "WIDTH", null, "FIT"),
534
+ box: boxOf(v),
535
+ ...textLook(v, "CUSTOM"),
536
+ part: "price",
537
+ showCompare: v.SHOW_COMPARE !== false,
538
+ priceVars: compact({ "--cb-sale": v.SALE_COLOR, "--cb-was": v.COMPARE_COLOR }),
539
+ };
540
+
541
+ case "CARD_SWATCHES":
542
+ return {
543
+ ...base,
544
+ box: boxOf(v, { background: false }),
545
+ max: num(v.MAX, 5),
546
+ square: upper(v.SHAPE, "CIRCLE") === "SQUARE",
547
+ // COLOR: the colour option only; ALL: every option (sizes as text).
548
+ show: upper(v.SHOW, "ALL"),
549
+ swatchVars: { "--cb-sw": `${num(v.SIZE, 16)}px`, "--cb-sw-gap": `${num(v.GAP, 6)}px` },
550
+ };
551
+
552
+ case "CARD_BUTTON":
553
+ return {
554
+ ...base,
555
+ ...sizeOf(v, "WIDTH", null, "FIT"),
556
+ box: boxOf(v, { background: false }),
557
+ label: t(v.LABEL) || (has(v.ACTION) ? "Add to cart" : "Shop now"),
558
+ // Product cards: QUICK_BUY | ADD_TO_CART | LINK. Classification cards have no
559
+ // ACTION — their button is only a look.
560
+ action: upper(v.ACTION, "LINK"),
561
+ optionsLabel: t(v.OPTIONS_LABEL) || "Choose options",
562
+ soldOutLabel: t(v.SOLD_OUT_LABEL) || "Sold out",
563
+ quickBuyStyle: upper(v.QUICK_BUY_STYLE, "SHEET"),
564
+ look: upper(v.LOOK, "TEXT"),
565
+ onImage: upper(v.PLACEMENT, "IN_CARD") === "ON_IMAGE",
566
+ // null: same as desktop.
567
+ onImageMobile: { IN_CARD: false, ON_IMAGE: true }[upper(v.PLACEMENT_MOBILE, "SAME")] ?? null,
568
+ imagePosition: upper(v.IMAGE_POSITION, "BOTTOM").toLowerCase().replace("_", "-"),
569
+ imageOffset: px(v.IMAGE_OFFSET),
570
+ revealOnHover: upper(v.REVEAL, "HOVER") === "HOVER",
571
+ // The icon (ICON looks): a preset or the merchant's SVG, and its sizes.
572
+ icon: upper(v.ICON, "BAG"),
573
+ iconSvg: upper(v.ICON, "BAG") === "CUSTOM" ? String(v.ICON_SVG || "").trim() : "",
574
+ btnVars: compact({ "--cb-btn-icon": px(v.ICON_SIZE), "--cb-btn-size": px(v.CIRCLE_SIZE) }),
575
+ };
576
+
577
+ case "BUTTON": {
578
+ const label = t(v.LABEL);
579
+ if (!label || !v.URL) return null;
580
+ return {
581
+ ...base,
582
+ ...sizeOf(v, "WIDTH", null, "FIT"),
583
+ box: boxOf(v, { background: false }),
584
+ label,
585
+ url: v.URL,
586
+ newTab: !!v.OPEN_IN_NEW_TAB,
587
+ };
588
+ }
589
+
590
+ case "ICON":
591
+ return {
592
+ ...base,
593
+ box: boxOf(v),
594
+ iconVars: compact({ "--cb-icon": px(v.SIZE), color: v.COLOR }),
595
+ icon: v.ICON_PRESET,
596
+ svg: String(v.ICON_SVG || "").trim(),
597
+ link: v.LINK_URL || null,
598
+ };
599
+
600
+ case "SPACER": {
601
+ const size = upper(v.SIZE, "MEDIUM");
602
+ return {
603
+ ...base,
604
+ box: paddingOf({}),
605
+ spacerVars: compact({
606
+ "--cb-sp": `${size === "CUSTOM" ? num(v.SIZE_CUSTOM, 40) : SPACES[size] ?? SPACES.MEDIUM}px`,
607
+ "--cb-sp-m": px(v.SIZE_MOBILE),
608
+ }),
609
+ };
610
+ }
611
+
612
+ case "RATING": {
613
+ const value = Math.max(0, Math.min(5, Number(v.RATING ?? 5) || 0));
614
+ return {
615
+ ...base,
616
+ box: boxOf(v),
617
+ value,
618
+ // Each star's filled share, 0..1 — 4.5 → five stars, the last half full.
619
+ stars: [0, 1, 2, 3, 4].map((i) => Math.max(0, Math.min(1, value - i))),
620
+ // A set empty colour is used as is; unset, empty stars are a faint
621
+ // version of the text colour.
622
+ customOff: has(v.EMPTY_COLOR),
623
+ ratingVars: compact({
624
+ "--cb-star": px(num(v.SIZE, 18)),
625
+ "--cb-star-gap": px(num(v.GAP, 2)),
626
+ "--cb-star-on": v.COLOR || "#f5a623",
627
+ "--cb-star-off": v.EMPTY_COLOR,
628
+ }),
629
+ };
630
+ }
631
+
632
+ case "ACCORDION": {
633
+ if (!children.length && !editing.value) return null;
634
+ const grid = upper(v.LAYOUT, "ACCORDION") === "GRID";
635
+ const tag = { H2: "h2", H3: "h3", H4: "h4", P: "p" }[upper(v.QUESTION_TAG, "H3")] ?? "h3";
636
+ const context = {
637
+ grid,
638
+ tag,
639
+ // Same name = the browser keeps one open at a time, natively.
640
+ name: v.ONE_AT_A_TIME !== false ? `faq-${base.key}` : undefined,
641
+ icon: upper(v.ICON_STYLE, "PLUS_MINUS"),
642
+ iconStart: upper(v.ICON_POSITION, "END") === "START",
643
+ };
644
+ let first = true;
645
+ for (const child of children) {
646
+ if (child.type !== "FAQ_ITEM") continue;
647
+ child.acc = context;
648
+ if (first && v.OPEN_FIRST !== false && !grid) child.open = true;
649
+ first = false;
650
+ }
651
+ const divider = upper(v.DIVIDER, "DIVIDER");
652
+ const typeVars = (prefix, key) =>
653
+ compact({
654
+ [`--faq-${key}-fs`]: px(v[`${prefix}_SIZE`]),
655
+ [`--faq-${key}-fs-m`]: px(v[`${prefix}_SIZE_MOBILE`]),
656
+ [`--faq-${key}-fw`]: Number(v[`${prefix}_WEIGHT`]) || undefined,
657
+ [`--faq-${key}-ff`]: v[`${prefix}_FONT`] === "HEADING" ? "var(--osi-font-heading)" : undefined,
658
+ [`--faq-${key}-lh`]: has(v[`${prefix}_LINE_HEIGHT`]) ? String(num(v[`${prefix}_LINE_HEIGHT`], 150) / 100) : undefined,
659
+ [`--faq-${key}-color`]: v[`${prefix}_COLOR`],
660
+ });
661
+ return {
662
+ ...base,
663
+ ...sizeOf(v, "WIDTH"),
664
+ box: boxOf(v),
665
+ accClasses: [grid ? "is-grid" : "is-list", `rows-${divider.toLowerCase()}`],
666
+ accVars: {
667
+ "--faq-gap": `${num(v.ITEM_SPACING, 0)}px`,
668
+ "--faq-cols": String(num(v.COLUMNS, 2)),
669
+ "--faq-cols-m": String(num(v.COLUMNS_MOBILE, 1)),
670
+ "--faq-radius": `${num(v.ROW_RADIUS, 8)}px`,
671
+ "--faq-py": `${num(v.ROW_PADDING_Y, 16)}px`,
672
+ "--faq-px": `${num(v.ROW_PADDING_X, 0)}px`,
673
+ "--faq-icon": `${num(v.ICON_SIZE, 12)}px`,
674
+ ...compact({
675
+ "--faq-line": v.DIVIDER_COLOR,
676
+ "--faq-row-bg": v.ROW_BG,
677
+ "--faq-icon-color": v.ICON_COLOR,
678
+ }),
679
+ ...typeVars("QUESTION", "q"),
680
+ ...typeVars("ANSWER", "a"),
681
+ },
682
+ };
683
+ }
684
+
685
+ case "FAQ_ITEM": {
686
+ const question = t(v.QUESTION);
687
+ if (!question && !editing.value) return null;
688
+ return {
689
+ ...base,
690
+ box: paddingOf({}),
691
+ question: question || "Question",
692
+ answer: t(v.ANSWER),
693
+ open: v.OPEN === true,
694
+ // Replaced by the accordion it sits in; a lone question uses these.
695
+ acc: { grid: false, tag: "h3", name: undefined, icon: "PLUS_MINUS", iconStart: false },
696
+ };
697
+ }
698
+
699
+ default: {
700
+ // A section's own block types (the footer's Menu, Logo…): the
701
+ // section passes options.custom = { TYPE: Component }. Drawn by that
702
+ // component with the node's box / width and its raw settings.
703
+ // options.raw: types the section draws itself from the tree (the
704
+ // classification page's Toolbar / Product grid) — kept as plain nodes.
705
+ if (options.raw?.includes(block.type)) return base;
706
+ const component = options.custom?.[block.type];
707
+ if (!component) return null;
708
+ // options.customText: { TYPE: default preset } — blocks with the Text
709
+ // block's look (a banner's title…) get it resolved like Text does.
710
+ const textDefault = options.customText?.[block.type];
711
+ return {
712
+ ...base,
713
+ ...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
714
+ box: boxOf(v, { background: false }),
715
+ ...(textDefault ? textLook(v, textDefault) : {}),
716
+ component: markRaw(component),
717
+ };
718
+ }
719
+ }
720
+ }
721
+
722
+ /**
723
+ * VISIBILITY (every content block): shown everywhere, or only on phones /
724
+ * only on larger screens. A class the parent puts on the node's element
725
+ * (ContentNode's `vis-*`), so it's CSS — no flash, works with SSR.
726
+ */
727
+ const VIS = { DESKTOP: "vis-desktop", MOBILE: "vis-mobile" };
728
+ function withVisibility(block, node) {
729
+ if (!node) return node;
730
+ const vis = VIS[upper(block.settings?.VISIBILITY, "ALL")];
731
+ return vis ? { ...node, visClass: vis } : node;
732
+ }
733
+
734
+ const tree = computed(() => {
735
+ const blocks = toValue(section)?.blocks ?? [];
736
+ const keys = new Set(blocks.map((b) => b.key));
737
+ const byParent = new Map();
738
+ for (const b of blocks) {
739
+ const parent = b.parent_key || "";
740
+ // A parent that isn't here (a hidden group the API left out) takes its
741
+ // children with it — lifting them to the top would scramble the layout.
742
+ if (parent && !keys.has(parent)) continue;
743
+ if (!byParent.has(parent)) byParent.set(parent, []);
744
+ byParent.get(parent).push(b);
745
+ }
746
+ firstImage = true;
747
+ const build = (parent, depth) =>
748
+ depth > 8
749
+ ? []
750
+ : (byParent.get(parent) ?? [])
751
+ .filter((b) => !b.disabled)
752
+ .map((b) => withVisibility(b, resolve(b, CONTAINERS.has(b.type) ? build(b.key, depth + 1) : [], parent === "")))
753
+ .filter(Boolean);
754
+ const children = build("", 0);
755
+ return {
756
+ key: "root",
757
+ type: "GROUP",
758
+ children,
759
+ ...flexOf({ ...(options.rootDefaults ?? {}), ...s.value }),
760
+ sizeClasses: [],
761
+ sizeVars: {},
762
+ box: paddingOf({}),
763
+ bg: null,
764
+ overlay: null,
765
+ link: null,
766
+ };
767
+ });
768
+
769
+ return { tree, backgroundOf, editing, u: { has, num, px, compact, upper } };
770
+ }