@osimart/create-storefront 0.0.0-stage → 1.0.0

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.ts +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.ts +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 +259 -0
  200. package/nuxt/osi-sections.ts +56 -0
  201. package/nuxt.config.ts +60 -0
  202. package/package.json +43 -3
  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,419 @@
1
+ /**
2
+ * CONTACT_INFO — address / contact / hours cards, plus social links.
3
+ *
4
+ * Every card is conditional, and the grid counts what's actually present
5
+ * instead of assuming three: a merchant who filled in only an address gets one
6
+ * card, not one card and two empty columns. COLUMNS is clamped to that count
7
+ * for the same reason.
8
+ *
9
+ * Icons are built in, one per contact type and one per SocialPlatform token.
10
+ * Nothing is typed by the merchant and no icon package is pulled in — the
11
+ * markup lives in ICONS below and renders inside a shared <svg>. It's a fixed,
12
+ * developer-authored constant, which is the only reason v-html is safe here.
13
+ *
14
+ * Typography follows SiteFooter.vue: text_style() emits
15
+ * <PREFIX>_SIZE/_WEIGHT/_COLOR/... on the backend, textStyleVars() turns one
16
+ * prefix into the generic --osi-fs/--osi-fw/... variables, and the element
17
+ * carries them inline. Two prefixes here: CARD_TITLE and CARD_TEXT.
18
+ *
19
+ * section HEADING, SUBHEADING, ADDRESS_*, MAP_URL, CONTACT_*, PHONE*,
20
+ * WHATSAPP*, EMAIL*, HOURS*, LAYOUT, COLUMNS, ITEM_GAP,
21
+ * CONTENT_ALIGNMENT, EQUAL_HEIGHT, ITEM_VERTICAL_ALIGNMENT,
22
+ * MOBILE_LAYOUT, CARD_*, SHOW_ICONS, ICON_*, SHOW_DIRECTIONS,
23
+ * DIRECTIONS_*, UNDERLINE_LINKS, OPEN_IN_NEW_TAB, SOCIAL_*,
24
+ * CARD_TITLE_*, CARD_TEXT_* + COMMON_STYLE_SETTINGS
25
+ * block PLATFORM (SocialPlatform token), URL, LABEL (localized)
26
+ */
27
+
28
+
29
+ export default function useLogic({ props }) {
30
+ const { s, t, shellStyle, innerStyle, align, customClass, heading } =
31
+ useSectionShell(() => props.section);
32
+
33
+ /* ------------------------------------------------------------------ utils */
34
+
35
+ function num(value, fallback) {
36
+ const n = Number(value);
37
+ return Number.isFinite(n) ? n : fallback;
38
+ }
39
+
40
+ /** Empty string, null and undefined mean "not set" — 0 does not. */
41
+ function has(value) {
42
+ return value !== undefined && value !== null && value !== "";
43
+ }
44
+
45
+ /** Strips absent keys so an unset setting emits no CSS variable at all. */
46
+ function compact(object) {
47
+ return Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined));
48
+ }
49
+
50
+ function pxVar(value) {
51
+ return has(value) && Number.isFinite(Number(value)) ? `${Number(value)}px` : undefined;
52
+ }
53
+
54
+ /** Picks value when it's one of allowed, else the first entry as the default. */
55
+ function oneOf(value, allowed) {
56
+ return allowed.includes(value) ? value : allowed[0];
57
+ }
58
+
59
+ const TEXT_ALIGNMENTS = { LEFT: "start", CENTER: "center", RIGHT: "end" };
60
+ const FLEX_ALIGNMENTS = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" };
61
+ const VERTICAL_ALIGNMENTS = { TOP: "start", CENTER: "center", BOTTOM: "end" };
62
+ const TEXT_TRANSFORMS = {
63
+ NONE: "none",
64
+ UPPERCASE: "uppercase",
65
+ LOWERCASE: "lowercase",
66
+ CAPITALIZE: "capitalize",
67
+ };
68
+ // FontWeight stores CSS numbers as strings; the token form is accepted too so
69
+ // a field written against either shape resolves without a migration.
70
+ const FONT_WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
71
+ const FONT_FAMILIES = { HEADING: "var(--osi-font-heading)", BODY: "var(--osi-font-body)" };
72
+ const SHADOWS = {
73
+ NONE: "none",
74
+ SMALL: "0 1px 2px rgba(0,0,0,.06)",
75
+ MEDIUM: "0 6px 16px rgba(0,0,0,.08)",
76
+ LARGE: "0 16px 40px rgba(0,0,0,.12)",
77
+ };
78
+
79
+ /** One text_style() prefix -> the generic vars, as SiteFooter.vue does it. */
80
+ function textStyleVars(prefix, defaultTransform, fallbackSize) {
81
+ const v = s.value;
82
+ const size = num(v[`${prefix}_SIZE`], fallbackSize);
83
+ const weight = FONT_WEIGHTS[v[`${prefix}_WEIGHT`]] ?? num(v[`${prefix}_WEIGHT`], 400);
84
+ return {
85
+ "--osi-fs": `${size}px`,
86
+ "--osi-fs-mobile": `${num(v[`${prefix}_SIZE_MOBILE`], size)}px`,
87
+ "--osi-fw": weight,
88
+ "--osi-lh": `${num(v[`${prefix}_LINE_HEIGHT`], 130) / 100}`,
89
+ "--osi-ls": `${num(v[`${prefix}_LETTER_SPACING`], 0) / 100}em`,
90
+ "--osi-ff": FONT_FAMILIES[v[`${prefix}_FONT`]] ?? "inherit",
91
+ color: v[`${prefix}_COLOR`] || undefined,
92
+ textTransform:
93
+ TEXT_TRANSFORMS[v[`${prefix}_TRANSFORM`]] ?? TEXT_TRANSFORMS[defaultTransform],
94
+ };
95
+ }
96
+
97
+ const titleStyle = computed(() => textStyleVars("CARD_TITLE", "UPPERCASE", 13));
98
+ const textStyle = computed(() => textStyleVars("CARD_TEXT", "NONE", 14));
99
+
100
+ /* ------------------------------------------------------------------ icons */
101
+
102
+ // 24x24, currentColor, stroke-based so one size/colour setting drives them all.
103
+ // Brand marks are simplified geometry, not official logo paths — swap for
104
+ // licensed assets if that matters. Keep in step with the footer's icon map.
105
+ const ICONS = {
106
+ address:
107
+ '<path d="M12 21s7-5.4 7-11a7 7 0 1 0-14 0c0 5.6 7 11 7 11Z"/><circle cx="12" cy="10" r="2.6"/>',
108
+ phone:
109
+ '<path d="M5 4h3l1.8 4.5-2 1.2a12.5 12.5 0 0 0 6.5 6.5l1.2-2L20 16v3a2 2 0 0 1-2.2 2A16.5 16.5 0 0 1 3 6.2 2 2 0 0 1 5 4Z"/>',
110
+ whatsapp:
111
+ '<path d="M4.2 19.8 5.6 16A8 8 0 1 1 9 19.2l-4.8.6Z"/><path d="M9.4 9.2c.4 1.7 2.2 3.5 3.9 3.9l.9-1.1 1.7.8-.3 1.5c-2.7.4-6-2.9-5.8-5.6l1.4-.5.2 1Z"/>',
112
+ email: '<rect x="3" y="5" width="18" height="14" rx="2.5"/><path d="m3.8 7.5 8.2 5.6 8.2-5.6"/>',
113
+ hours: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.2V12l3.2 2"/>',
114
+ directions: '<path d="M12 3.2 20.5 20.8 12 17l-8.5 3.8L12 3.2Z"/>',
115
+
116
+ FACEBOOK:
117
+ '<path d="M14.5 21v-7h2.3l.4-3h-2.7V9.2c0-.9.3-1.4 1.5-1.4H17V5.1c-.3 0-1.2-.1-2.2-.1-2.2 0-3.7 1.3-3.7 3.8V11H9v3h2.1v7Z" fill="currentColor" stroke="none"/>',
118
+ INSTAGRAM:
119
+ '<rect x="3.5" y="3.5" width="17" height="17" rx="5"/><circle cx="12" cy="12" r="4"/><circle cx="17" cy="7" r="1.1" fill="currentColor" stroke="none"/>',
120
+ X: '<path d="M4.5 4.5 19.5 19.5M19.5 4.5 4.5 19.5"/>',
121
+ TIKTOK:
122
+ '<path d="M14 4v9.6a3.6 3.6 0 1 1-3.1-3.6"/><path d="M14 4.2c.5 2.3 2.2 3.7 4.5 3.9"/>',
123
+ YOUTUBE:
124
+ '<rect x="2.5" y="6" width="19" height="12" rx="4"/><path d="M10.8 9.4 15.4 12l-4.6 2.6Z" fill="currentColor" stroke="none"/>',
125
+ LINKEDIN:
126
+ '<rect x="3.5" y="3.5" width="17" height="17" rx="3.5"/><path d="M7.8 10.4v6.4M7.8 7.3v.1M11.4 16.8v-4a2.4 2.4 0 0 1 4.8 0v4"/>',
127
+ WHATSAPP:
128
+ '<path d="M4.2 19.8 5.6 16A8 8 0 1 1 9 19.2l-4.8.6Z"/><path d="M9.4 9.2c.4 1.7 2.2 3.5 3.9 3.9l.9-1.1 1.7.8-.3 1.5c-2.7.4-6-2.9-5.8-5.6l1.4-.5.2 1Z"/>',
129
+ TELEGRAM:
130
+ '<path d="M21 4.5 2.8 11.4l5.4 1.8L18.6 7l-8.4 8 .3 4.4 2.8-3.4 4.3 3L21 4.5Z"/>',
131
+ PINTEREST:
132
+ '<circle cx="12" cy="12" r="8.5"/><path d="M10.6 20.2c-.6-2 .1-4.5.6-6.5M9.8 12.6c-.9-2.7.8-5.3 3.5-5.3 2.1 0 3.6 1.4 3.6 3.4 0 2.4-1.4 4.2-3.2 4.2-.9 0-1.5-.7-1.3-1.5"/>',
133
+ SNAPCHAT:
134
+ '<path d="M12 4.2c2.4 0 3.9 1.8 3.9 4.1 0 1.6-.2 2.3.6 2.6.6.2 1.3-.4 1.6.2.3.6-.6 1.1-1.6 1.5-.5.2-.3.8.3 1.5.6.7 1.6 1 2 1.1.4.1.3.6-.3.9-.9.4-1.8.3-2.2.8-.2.3-.1.8-.7.8-.8 0-1.4-.5-2.5-.5s-1.7.5-2.5.5c-.6 0-.5-.5-.7-.8-.4-.5-1.3-.4-2.2-.8-.6-.3-.7-.8-.3-.9.4-.1 1.4-.4 2-1.1.6-.7.8-1.3.3-1.5-1-.4-1.9-.9-1.6-1.5.3-.6 1 0 1.6-.2.8-.3.6-1 .6-2.6 0-2.3 1.5-4.1 3.7-4.1Z"/>',
135
+ WEBSITE:
136
+ '<path d="M10.3 13.7a3.2 3.2 0 0 0 4.6 0l2.6-2.6a3.2 3.2 0 0 0-4.6-4.6l-1.1 1.1"/><path d="M13.7 10.3a3.2 3.2 0 0 0-4.6 0l-2.6 2.6a3.2 3.2 0 0 0 4.6 4.6l1.1-1.1"/>',
137
+ };
138
+
139
+ const PLATFORM_LABELS = {
140
+ FACEBOOK: "Facebook",
141
+ INSTAGRAM: "Instagram",
142
+ X: "X",
143
+ TIKTOK: "TikTok",
144
+ YOUTUBE: "YouTube",
145
+ LINKEDIN: "LinkedIn",
146
+ WHATSAPP: "WhatsApp",
147
+ TELEGRAM: "Telegram",
148
+ PINTEREST: "Pinterest",
149
+ SNAPCHAT: "Snapchat",
150
+ WEBSITE: "Website",
151
+ };
152
+
153
+ /* ---------------------------------------------------------------- content */
154
+
155
+ const address = computed(() => t(s.value.ADDRESS));
156
+ const hours = computed(() => t(s.value.HOURS));
157
+ const subheading = computed(() => t(s.value.SUBHEADING));
158
+
159
+ // tel: strips everything but digits and a leading +; wa.me needs bare digits.
160
+ const telHref = computed(() => `tel:${String(s.value.PHONE ?? "").replace(/[^\d+]/g, "")}`);
161
+ const waHref = computed(
162
+ () => `https://wa.me/${String(s.value.WHATSAPP ?? "").replace(/\D/g, "")}`
163
+ );
164
+
165
+ /** target/rel as one object so every external link gets both, or neither. */
166
+ const external = computed(() =>
167
+ s.value.OPEN_IN_NEW_TAB !== false ? { target: "_blank", rel: "noopener noreferrer" } : {}
168
+ );
169
+
170
+ const contactLinks = computed(() => {
171
+ const out = [];
172
+ if (s.value.PHONE) {
173
+ out.push({
174
+ id: "phone",
175
+ icon: "phone",
176
+ href: telHref.value,
177
+ text: t(s.value.PHONE_LABEL) || s.value.PHONE,
178
+ external: false,
179
+ });
180
+ }
181
+ if (s.value.WHATSAPP) {
182
+ out.push({
183
+ id: "whatsapp",
184
+ icon: "whatsapp",
185
+ href: waHref.value,
186
+ text: t(s.value.WHATSAPP_LABEL) || s.value.WHATSAPP,
187
+ external: true,
188
+ });
189
+ }
190
+ if (s.value.EMAIL) {
191
+ out.push({
192
+ id: "email",
193
+ icon: "email",
194
+ href: `mailto:${s.value.EMAIL}`,
195
+ text: t(s.value.EMAIL_LABEL) || s.value.EMAIL,
196
+ external: false,
197
+ });
198
+ }
199
+ return out;
200
+ });
201
+
202
+ const directions = computed(() => {
203
+ if (!s.value.MAP_URL || s.value.SHOW_DIRECTIONS === false) return null;
204
+ return {
205
+ href: s.value.MAP_URL,
206
+ label: t(s.value.DIRECTIONS_LABEL) || "Get directions",
207
+ };
208
+ });
209
+
210
+ // Split on the merchant's own line breaks — no parsing of days or times, so
211
+ // nothing about their formatting is second-guessed.
212
+ const hoursRows = computed(() =>
213
+ hours.value
214
+ .split(/\r?\n/)
215
+ .map((line) => line.trim())
216
+ .filter(Boolean)
217
+ );
218
+ const hoursAsRows = computed(
219
+ () => s.value.HOURS_STYLE === "ROWS" && hoursRows.value.length > 1
220
+ );
221
+
222
+ const socials = computed(() =>
223
+ (props.section.blocks ?? [])
224
+ .map((block) => {
225
+ const b = block.settings ?? {};
226
+ // Tolerates the free-text platform names saved before PLATFORM became a
227
+ // select ('instagram', 'Instagram') so old blocks keep their icon.
228
+ const raw = String(b.PLATFORM ?? "").trim().toUpperCase();
229
+ const platform = ICONS[raw] ? raw : "WEBSITE";
230
+ return {
231
+ key: block.key,
232
+ platform,
233
+ label: t(b.LABEL) || PLATFORM_LABELS[platform] || "Link",
234
+ url: b.URL,
235
+ };
236
+ })
237
+ .filter((item) => item.url)
238
+ );
239
+
240
+ const socialPosition = computed(() =>
241
+ socials.value.length ? oneOf(s.value.SOCIAL_POSITION, ["BELOW", "ABOVE", "CARD"]) : null
242
+ );
243
+
244
+ /* ----------------------------------------------------------------- items */
245
+
246
+ /**
247
+ * The cards, in render order, with everything each one needs resolved.
248
+ * Building the list here rather than as three v-ifs in the template is what
249
+ * lets COLUMNS clamp to the real count, and keeps the "never an empty card"
250
+ * rule in one place.
251
+ */
252
+ const items = computed(() => {
253
+ const out = [];
254
+
255
+ if (address.value) {
256
+ out.push({
257
+ id: "address",
258
+ icon: "address",
259
+ title: t(s.value.ADDRESS_TITLE),
260
+ text: address.value,
261
+ });
262
+ }
263
+
264
+ if (contactLinks.value.length) {
265
+ out.push({
266
+ id: "contact",
267
+ icon: "phone",
268
+ title: t(s.value.CONTACT_TITLE),
269
+ links: contactLinks.value,
270
+ });
271
+ }
272
+
273
+ if (hours.value) {
274
+ out.push({
275
+ id: "hours",
276
+ icon: "hours",
277
+ title: t(s.value.HOURS_TITLE),
278
+ text: hours.value,
279
+ rows: hoursAsRows.value ? hoursRows.value : null,
280
+ });
281
+ }
282
+
283
+ if (socialPosition.value === "CARD") {
284
+ out.push({
285
+ id: "social",
286
+ icon: "WEBSITE",
287
+ title: t(s.value.SOCIAL_TITLE),
288
+ social: true,
289
+ });
290
+ }
291
+
292
+ return out;
293
+ });
294
+
295
+ /* ---------------------------------------------------------------- layout */
296
+
297
+ const layout = computed(() => oneOf(s.value.LAYOUT, ["CARDS", "INLINE"]));
298
+ const cardStyle = computed(() =>
299
+ oneOf(s.value.CARD_STYLE, ["OUTLINE", "FILLED", "ELEVATED", "MINIMAL"])
300
+ );
301
+ const contentAlign = computed(() =>
302
+ oneOf(s.value.CONTENT_ALIGNMENT, ["LEFT", "CENTER", "RIGHT"])
303
+ );
304
+
305
+ const showIcons = computed(() => s.value.SHOW_ICONS !== false);
306
+ const iconPosition = computed(() =>
307
+ showIcons.value ? oneOf(s.value.ICON_POSITION, ["TOP", "START", "INLINE"]) : "NONE"
308
+ );
309
+ const iconShape = computed(() => oneOf(s.value.ICON_SHAPE, ["PLAIN", "CIRCLE", "SQUARE"]));
310
+ // Fill only means something inside a shape; PLAIN has no box to fill.
311
+ const iconFill = computed(() =>
312
+ iconShape.value === "PLAIN" ? "OUTLINE" : oneOf(s.value.ICON_FILL, ["FILLED", "OUTLINE"])
313
+ );
314
+
315
+ const socialDisplay = computed(() =>
316
+ oneOf(s.value.SOCIAL_DISPLAY, ["ICONS", "ICON_TEXT", "TEXT"])
317
+ );
318
+ const socialShowsIcon = computed(() => socialDisplay.value !== "TEXT");
319
+ const socialShowsText = computed(() => socialDisplay.value !== "ICONS");
320
+ const socialShape = computed(() =>
321
+ socialShowsIcon.value
322
+ ? oneOf(s.value.SOCIAL_ICON_SHAPE, ["PLAIN", "CIRCLE", "SQUARE"])
323
+ : "PLAIN"
324
+ );
325
+ const socialFill = computed(() =>
326
+ socialShape.value === "PLAIN"
327
+ ? "OUTLINE"
328
+ : oneOf(s.value.SOCIAL_ICON_FILL, ["FILLED", "OUTLINE"])
329
+ );
330
+
331
+ /**
332
+ * Grid columns, clamped by how many cards actually exist — three columns with
333
+ * one filled-in card is one full-width card, never one card and two holes.
334
+ * repeat() can't take min(), so the clamp happens here.
335
+ */
336
+ const gridStyle = computed(() => {
337
+ if (layout.value !== "CARDS") return {};
338
+ const wanted = Math.max(1, Math.min(4, num(s.value.COLUMNS, 3)));
339
+ const columns = Math.max(1, Math.min(wanted, items.value.length));
340
+ return { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` };
341
+ });
342
+
343
+ const listStyle = computed(() => {
344
+ if (layout.value === "INLINE") {
345
+ return { justifyContent: FLEX_ALIGNMENTS[contentAlign.value] };
346
+ }
347
+ if (s.value.EQUAL_HEIGHT === false) {
348
+ return { alignItems: VERTICAL_ALIGNMENTS[s.value.ITEM_VERTICAL_ALIGNMENT] ?? "start" };
349
+ }
350
+ return {};
351
+ });
352
+
353
+ const socialListStyle = computed(() => ({
354
+ justifyContent: FLEX_ALIGNMENTS[oneOf(s.value.SOCIAL_ALIGNMENT, ["CENTER", "LEFT", "RIGHT"])],
355
+ // Above/below sit in a flex column with the header and the grid; order is
356
+ // what places them, so there's no third copy of this markup below.
357
+ order: socialPosition.value === "ABOVE" ? -1 : 1,
358
+ }));
359
+
360
+ /* -------------------------------------------------------------- variables */
361
+
362
+ const styleVars = computed(() =>
363
+ compact({
364
+ // Unset inherits the theme's grid gap rather than a number invented here.
365
+ "--osi-ci-gap": pxVar(s.value.ITEM_GAP) ?? "var(--osi-grid-gap)",
366
+ "--osi-ci-align": TEXT_ALIGNMENTS[contentAlign.value],
367
+ "--osi-ci-flex-align": FLEX_ALIGNMENTS[contentAlign.value],
368
+
369
+ "--osi-ci-card-bg": s.value.CARD_BG_COLOR || undefined,
370
+ "--osi-ci-card-border": s.value.CARD_BORDER_COLOR || undefined,
371
+ "--osi-ci-card-radius": pxVar(s.value.CARD_BORDER_RADIUS),
372
+ "--osi-ci-card-pad": pxVar(s.value.CARD_PADDING),
373
+ "--osi-ci-card-shadow": SHADOWS[s.value.CARD_SHADOW],
374
+
375
+ "--osi-ci-icon-size": pxVar(s.value.ICON_SIZE),
376
+ "--osi-ci-icon-color": s.value.ICON_COLOR || undefined,
377
+ "--osi-ci-icon-bg": s.value.ICON_BG_COLOR || undefined,
378
+
379
+ "--osi-ci-social-size": pxVar(s.value.SOCIAL_ICON_SIZE),
380
+ "--osi-ci-social-gap": pxVar(s.value.SOCIAL_GAP),
381
+ "--osi-ci-social-color": s.value.SOCIAL_COLOR || undefined,
382
+ "--osi-ci-social-bg": s.value.SOCIAL_BG_COLOR || undefined,
383
+ })
384
+ );
385
+
386
+ const sectionStyle = computed(() => ({ ...shellStyle.value, ...styleVars.value }));
387
+
388
+ const rootClasses = computed(() => [
389
+ customClass.value,
390
+ `osi-ci--${layout.value.toLowerCase()}`,
391
+ `osi-ci--card-${cardStyle.value.toLowerCase()}`,
392
+ `osi-ci--icon-${iconPosition.value.toLowerCase()}`,
393
+ `osi-ci--iconshape-${iconShape.value.toLowerCase()}`,
394
+ `osi-ci--iconfill-${iconFill.value.toLowerCase()}`,
395
+ `osi-ci--mobile-${oneOf(s.value.MOBILE_LAYOUT, ["STACKED", "SWIPE"]).toLowerCase()}`,
396
+ { "osi-ci--plain-links": s.value.UNDERLINE_LINKS === false },
397
+ ]);
398
+
399
+ const socialClasses = computed(() => [
400
+ `osi-ci-socials--shape-${socialShape.value.toLowerCase()}`,
401
+ `osi-ci-socials--fill-${socialFill.value.toLowerCase()}`,
402
+ { "osi-ci-socials--text": !socialShowsIcon.value },
403
+ ]);
404
+
405
+ const directionsClasses = computed(() => {
406
+ const style = oneOf(s.value.DIRECTIONS_STYLE, ["UNDERLINE", "SOLID", "OUTLINE", "TEXT"]);
407
+ const size = oneOf(s.value.DIRECTIONS_SIZE, ["MEDIUM", "SMALL", "LARGE"]);
408
+ return [
409
+ "osi-btn",
410
+ `osi-btn--${style.toLowerCase()}`,
411
+ size === "SMALL" && "osi-btn--sm",
412
+ size === "LARGE" && "osi-btn--lg",
413
+ ].filter(Boolean);
414
+ });
415
+
416
+ const hasBody = computed(() => items.value.length > 0 || socials.value.length > 0);
417
+
418
+ return { s, t, shellStyle, innerStyle, align, customClass, heading, num, has, compact, pxVar, oneOf, TEXT_ALIGNMENTS, FLEX_ALIGNMENTS, VERTICAL_ALIGNMENTS, TEXT_TRANSFORMS, FONT_WEIGHTS, FONT_FAMILIES, SHADOWS, textStyleVars, titleStyle, textStyle, ICONS, PLATFORM_LABELS, address, hours, subheading, telHref, waHref, external, contactLinks, directions, hoursRows, hoursAsRows, socials, socialPosition, items, layout, cardStyle, contentAlign, showIcons, iconPosition, iconShape, iconFill, socialDisplay, socialShowsIcon, socialShowsText, socialShape, socialFill, gridStyle, listStyle, socialListStyle, styleVars, sectionStyle, rootClasses, socialClasses, directionsClasses, hasBody };
419
+ }
@@ -0,0 +1,189 @@
1
+ import { NuxtLink } from "#components";
2
+ import { h } from "vue";
3
+ import { buttonClasses as toButtonClasses, buttonVars } from "#osi/composables/useButtonStyle";
4
+
5
+ export default function useLogic({ props }) {
6
+ // Editor-only hints (an empty group's "Add blocks…") never reach the live store.
7
+ const { isPreview } = useBuilderPreview();
8
+
9
+ const NuxtLinkComponent = NuxtLink;
10
+ const STAR = "M12 2.8l2.84 5.76 6.36.92-4.6 4.49 1.09 6.33L12 17.31l-5.69 2.99 1.09-6.33-4.6-4.49 6.36-.92L12 2.8z";
11
+ const n = computed(() => props.node);
12
+
13
+ // Keep in sync with FEATURE_ICON_PRESET_CHOICES in builder/schemas.py.
14
+ const ICONS = {
15
+ truck: "M3 6h11v9H3zM14 9h4l3 3v3h-7M7.5 18.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3zM17.5 18.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z",
16
+ shield: "M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3z",
17
+ return: "M9 14L4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3",
18
+ headset: "M4 14v-2a8 8 0 0 1 16 0v2M4 14h3v5H5a1 1 0 0 1-1-1v-4zM20 14h-3v5h2a1 1 0 0 0 1-1v-4z",
19
+ gift: "M4 11h16v9H4zM3 7h18v4H3zM12 7v13M12 7c-1.5-3-5-3-5-1s3 1 5 1c2 0 5 1 5-1s-3.5-2-5 1z",
20
+ tag: "M3 12V4h8l10 10-8 8L3 12zM7.5 8.5h.01",
21
+ credit_card: "M3 6h18v12H3zM3 10h18M7 15h3",
22
+ leaf: "M5 19c0-9 6-14 15-14 0 9-5 15-14 15M5 19l7-7",
23
+ star: "M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8L3.5 9.7l5.9-.9L12 3.5z",
24
+ clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2",
25
+ globe: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3c2.5 2.5 3.5 5.5 3.5 9s-1 6.5-3.5 9c-2.5-2.5-3.5-5.5-3.5-9s1-6.5 3.5-9z",
26
+ heart: "M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.5-7 10-7 10z",
27
+ };
28
+
29
+ /* The wishlist button's icons (CARD_IMAGE WISHLIST_ICON). */
30
+ const WISH_ICONS = {
31
+ HEART: "M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z",
32
+ STAR: "M11.48 3.5a.56.56 0 0 1 1.04 0l2.12 5.11a.56.56 0 0 0 .48.35l5.52.44c.5.04.7.66.32.99l-4.2 3.6a.56.56 0 0 0-.18.56l1.28 5.39a.56.56 0 0 1-.84.61l-4.73-2.89a.56.56 0 0 0-.58 0l-4.73 2.89a.56.56 0 0 1-.84-.61l1.28-5.39a.56.56 0 0 0-.18-.56l-4.2-3.6a.56.56 0 0 1 .32-.99l5.52-.44a.56.56 0 0 0 .48-.35l2.12-5.11Z",
33
+ BOOKMARK: "M17.6 3.32c1.1.13 1.9 1.07 1.9 2.18V21L12 17.25 4.5 21V5.5c0-1.11.8-2.05 1.9-2.18a48.5 48.5 0 0 1 11.2 0Z",
34
+ };
35
+ /* The buy button's icons (CARD_BUTTON ICON). */
36
+ const BTN_ICONS = {
37
+ BAG: "M15.75 10.5V6a3.75 3.75 0 1 0-7.5 0v4.5m11.356-1.993 1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 0 1-1.12-1.243l1.264-12A1.125 1.125 0 0 1 5.513 7.5h12.974c.576 0 1.059.435 1.119 1.007Z",
38
+ CART: "M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z",
39
+ PLUS: "M12 4.5v15m7.5-7.5h-15",
40
+ };
41
+ const WISH_DEFAULT = { icon: "HEART", svg: "", position: "top-right", plain: false, hover: false, vars: {} };
42
+
43
+ const button = computed(() => {
44
+ if (n.value.type !== "BUTTON") return null;
45
+ const s = n.value.s;
46
+ return { style: buttonVars(s, "BUTTON"), classes: toButtonClasses(s, "BUTTON", ["osi-cb-btn"]) };
47
+ });
48
+
49
+ // Wrapper element: a link when the block has one (never nested — the tree
50
+ // builder drops a group's link when its subtree already holds links).
51
+ const wrapperTag = computed(() => (n.value.link && !props.item ? NuxtLinkComponent : "div"));
52
+
53
+ /* ---- card template parts */
54
+ const partText = computed(() => {
55
+ const item = props.item;
56
+ const node = n.value;
57
+ if (!item) {
58
+ // Drawn without an item (e.g. an empty list in the builder): sample text.
59
+ return { title: "Category name", count: "12 products", description: "A short description." }[node.part] ?? "";
60
+ }
61
+ if (node.part === "title") return [node.prefix, item.title].filter(Boolean).join(" ");
62
+ if (node.part === "count") {
63
+ if (item.count == null) return "";
64
+ return `${item.count} ${item.count === 1 ? node.singular : node.plural}`;
65
+ }
66
+ if (node.part === "description") return item.description || "";
67
+ return "";
68
+ });
69
+ const cardButton = computed(() => {
70
+ if (n.value.type !== "CARD_BUTTON") return null;
71
+ const s = n.value.s;
72
+ return { style: buttonVars(s, "BUTTON"), classes: toButtonClasses(s, "BUTTON", ["osi-cb-btn"]) };
73
+ });
74
+
75
+ /* ---- product card parts (PRODUCT_LIST: items from useProductItems) */
76
+ const isProduct = computed(() => props.item?.kind === "PRODUCT");
77
+ const analytics = useAnalytics();
78
+ function onCardClick(event) {
79
+ if (!isProduct.value) return;
80
+ if (event?.target?.closest?.("button, [role='button'], input, select, label")) return;
81
+ analytics.selectItem(props.item.raw);
82
+ }
83
+
84
+ /**
85
+ * The Buy button for this product: its label, and what a click does —
86
+ * add straight to cart, open quick buy (products with options), or nothing
87
+ * (the card's own link opens the product).
88
+ */
89
+ const productButton = computed(() => {
90
+ const node = n.value;
91
+ const item = props.item;
92
+ if (node.type !== "CARD_BUTTON" || !isProduct.value) return null;
93
+ if (item.soldOut) return { label: node.soldOutLabel, disabled: true };
94
+ // No price: not for sale yet — its normal label, disabled.
95
+ if (!item.price) return { label: node.label, disabled: true };
96
+ if (node.action === "LINK") return { label: node.label };
97
+ if (!item.hasOptions) return { label: node.label, adds: true };
98
+ if (node.action === "QUICK_BUY") return { label: node.optionsLabel, quick: true };
99
+ return { label: node.optionsLabel }; // ADD_TO_CART with options: the product page
100
+ });
101
+
102
+ /**
103
+ * The card is one link; buttons inside it can't be <button>s (interactive
104
+ * content inside <a>), so they're role=button spans that stop the link.
105
+ * Anything that doesn't act lets the click through to the card's link.
106
+ */
107
+ function onCardButton(e) {
108
+ const b = productButton.value;
109
+ if (!b || !(b.adds || b.quick || b.disabled)) return;
110
+ e.preventDefault();
111
+ e.stopPropagation();
112
+ if (b.adds) props.item.addToCart?.();
113
+ else if (b.quick) props.item.openQuickBuy?.(n.value.quickBuyStyle);
114
+ }
115
+ function onCardButtonKey(e) {
116
+ if (e.key === "Enter" || e.key === " ") onCardButton(e);
117
+ }
118
+ function onWishlist(e) {
119
+ e.preventDefault();
120
+ e.stopPropagation();
121
+ props.item?.toggleWishlist?.();
122
+ }
123
+
124
+ /**
125
+ * A Text block's element (<h1>…<h6> or <div>) with its HTML inside.
126
+ * Not `<component :is v-html>`: Vue's server renderer drops v-html on a
127
+ * dynamic component, so the server sent an empty <h2> and the title only
128
+ * appeared once the page hydrated. A plain element vnode with innerHTML is
129
+ * rendered on the server. class / style / data-* fall through to it.
130
+ */
131
+ const RichTag = (props) => h(props.tag || "div", { innerHTML: props.html ?? "" });
132
+ RichTag.props = ["tag", "html"];
133
+
134
+ /**
135
+ * Badges on a product image: coming soon / sold out / a custom badge / "Sale"
136
+ * for a 1-item sale — the first that applies — else one per quantity-tier
137
+ * sale ("Buy 8, save $6").
138
+ */
139
+ const productBadges = computed(() => {
140
+ const item = props.item;
141
+ const node = n.value;
142
+ if (!isProduct.value || node.type !== "CARD_IMAGE" || !node.badges) return [];
143
+ let status;
144
+ if (item.comingSoon) status = [{ key: "soon", label: "Coming soon", kind: "muted" }];
145
+ else if (item.soldOut) status = [{ key: "out", label: node.soldOutLabel, kind: "soldout" }];
146
+ else if (item.badge) status = [{ key: "custom", label: item.badge, kind: "custom" }];
147
+ else if (item.onSale) status = [{ key: "sale", label: node.saleLabel, kind: "sale" }];
148
+ else status = (item.offers ?? []).map((offer) => ({ key: offer.id, label: offer.text, kind: "sale" }));
149
+ // Then the brand and categories, when turned on (BADGE_BRAND / BADGE_CATEGORIES).
150
+ const info = [];
151
+ if (node.badgeBrand && item.brand) info.push({ key: "brand", label: item.brand, kind: "info" });
152
+ if (node.badgeCategories) {
153
+ for (const name of (item.categories ?? []).slice(0, 3)) info.push({ key: `cat-${name}`, label: name, kind: "info" });
154
+ }
155
+ return [...status, ...info];
156
+ });
157
+
158
+ /**
159
+ * The badges in their corners: one group, or two when the brand / category
160
+ * badges have their own corner (BADGE_INFO_POSITION) — each group only if it
161
+ * has badges.
162
+ */
163
+ const badgeGroups = computed(() => {
164
+ const all = productBadges.value;
165
+ const node = n.value;
166
+ const apart = node.badgeInfoPosition && node.badgeInfoPosition !== node.badgePosition;
167
+ if (!apart) return all.length ? [{ key: "all", position: node.badgePosition, badges: all }] : [];
168
+ const status = all.filter((b) => b.kind !== "info");
169
+ const info = all.filter((b) => b.kind === "info");
170
+ return [
171
+ status.length && { key: "status", position: node.badgePosition, badges: status },
172
+ info.length && { key: "info", position: node.badgeInfoPosition, badges: info },
173
+ ].filter(Boolean);
174
+ });
175
+
176
+ /** Swatch groups (one per option), each cut to MAX with a "+N". */
177
+ const swatchGroups = computed(() => {
178
+ const max = n.value.max ?? 5;
179
+ const options = (props.item?.options ?? []).filter((o) => n.value.show !== "COLOR" || o.isColor);
180
+ return options.map((o) => ({
181
+ key: o.key,
182
+ name: o.name,
183
+ shown: o.values.slice(0, max),
184
+ more: Math.max(0, o.values.length - max),
185
+ }));
186
+ });
187
+
188
+ return { isPreview, NuxtLinkComponent, STAR, n, ICONS, WISH_ICONS, BTN_ICONS, WISH_DEFAULT, button, wrapperTag, partText, cardButton, isProduct, analytics, onCardClick, productButton, onCardButton, onCardButtonKey, onWishlist, RichTag, productBadges, badgeGroups, swatchGroups, h };
189
+ }