@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,476 @@
1
+ import { SECTION_WIDTHS } from "../../composables/useSectionShell";
2
+
3
+ export default function useLogic({ props }) {
4
+ const isBar = computed(() => props.part === "bar");
5
+ // Utilities blocks' keys (FooterUtilitiesSection), to tag the parts.
6
+ const utilityBlocks = inject("osiUtilityBlocks", computed(() => ({})));
7
+
8
+ const settings = useThemeGroup("FOOTER");
9
+ const brand = useThemeGroup("BRAND");
10
+ /* ------------------------------------------------------------ appearance
11
+ * Like the header: a colour scheme with background / text / link overrides
12
+ * (empty = the scheme's). The bar under the footer (Utilities) follows the
13
+ * footer's scheme and overrides unless it has a scheme of its own.
14
+ */
15
+ const pick = (key) => {
16
+ const own = props.own?.[key];
17
+ return own !== undefined && own !== null && own !== "" ? own : settings.value[key];
18
+ };
19
+ const barScheme = computed(() => (isBar.value ? pick("FOOTER_BOTTOM_COLOR_SCHEME") : null));
20
+ const scheme = useColorScheme(
21
+ // Nothing picked: the theme's Default scheme (useColorScheme's fallback),
22
+ // never a hardcoded SCHEME_1 — so changing it in Theme settings repaints
23
+ // the footer and the bar, with or without a Footer section.
24
+ computed(() => barScheme.value || pick("FOOTER_COLOR_SCHEME") || null)
25
+ );
26
+ const appearance = computed(() => {
27
+ // A bar with its own scheme starts from that scheme, not the footer's colours.
28
+ const inherit = !barScheme.value;
29
+ const background = isBar.value
30
+ ? pick("FOOTER_BOTTOM_BACKGROUND") || (inherit ? pick("FOOTER_BACKGROUND") : null)
31
+ : pick("FOOTER_BACKGROUND");
32
+ const text = !isBar.value || inherit ? pick("FOOTER_TEXT_COLOR") : null;
33
+ const link = !isBar.value || inherit ? pick("FOOTER_LINK_COLOR") : null;
34
+ const hover = !isBar.value || inherit ? pick("FOOTER_LINK_HOVER_COLOR") : null;
35
+ return Object.fromEntries(Object.entries({
36
+ "--osi-bg": background || undefined,
37
+ "--osi-text": text || undefined,
38
+ "--osi-footer-link": link || undefined,
39
+ "--osi-footer-link-hover": hover || undefined,
40
+ }).filter(([, v]) => v !== undefined));
41
+ });
42
+
43
+ const { store, storeLogo } = useStorefront();
44
+ const { mediaUrl } = useMediaUrl();
45
+ const { useMenuByHandle } = useMenu();
46
+ const t = useT();
47
+
48
+ // Three fixed slots rather than a repeater: theme settings has no repeatable
49
+ // field type, and footer columns are menus the merchant already maintains.
50
+ // Empty handles resolve to empty menus and are filtered out below.
51
+ const menu1 = useMenuByHandle(computed(() => settings.value.FOOTER_MENU_1));
52
+ const menu2 = useMenuByHandle(computed(() => settings.value.FOOTER_MENU_2));
53
+ const menu3 = useMenuByHandle(computed(() => settings.value.FOOTER_MENU_3));
54
+
55
+ // Custom widths only take effect when FOOTER_COLUMN_WIDTH_MODE is CUSTOM;
56
+ // otherwise every column shares the row equally, exactly as before.
57
+ const columnWidthMode = computed(() => settings.value.FOOTER_COLUMN_WIDTH_MODE || "EQUAL");
58
+ function columnFlexBasis(widthSetting) {
59
+ if (columnWidthMode.value !== "CUSTOM") return null;
60
+ return `${widthSetting || 33}%`;
61
+ }
62
+
63
+ const columns = computed(() =>
64
+ [
65
+ {
66
+ menu: menu1.value,
67
+ title: t(settings.value.FOOTER_MENU_1_TITLE),
68
+ flexBasis: columnFlexBasis(settings.value.FOOTER_MENU_1_WIDTH),
69
+ },
70
+ {
71
+ menu: menu2.value,
72
+ title: t(settings.value.FOOTER_MENU_2_TITLE),
73
+ flexBasis: columnFlexBasis(settings.value.FOOTER_MENU_2_WIDTH),
74
+ },
75
+ {
76
+ menu: menu3.value,
77
+ title: t(settings.value.FOOTER_MENU_3_TITLE),
78
+ flexBasis: columnFlexBasis(settings.value.FOOTER_MENU_3_WIDTH),
79
+ },
80
+ ]
81
+ .filter((column) => column.menu.items.length)
82
+ .map((column) => ({ ...column, title: column.title || column.menu.title }))
83
+ );
84
+
85
+ // FOOTER_LOGO overrides BRAND.LOGO for this section only; empty falls through
86
+ // to the brand logo and then the store's default logo, same chain the old
87
+ // markup used before the override field existed.
88
+ const logoSrc = computed(() =>
89
+ mediaUrl(settings.value.FOOTER_LOGO || brand.value.LOGO || storeLogo.value)
90
+ );
91
+ const logoPosition = computed(() => settings.value.FOOTER_LOGO_POSITION || "TOP_CENTER");
92
+ const logoAlignment = computed(() => {
93
+ const map = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" };
94
+ return map[settings.value.FOOTER_LOGO_ALIGNMENT] ?? "center";
95
+ });
96
+
97
+ // Mobile width falls back to the desktop width so old configs (which only
98
+ // ever set FOOTER_LOGO_WIDTH) render identically on every breakpoint.
99
+ const logoWidthVars = computed(() => {
100
+ const desktop = settings.value.FOOTER_LOGO_WIDTH || 64;
101
+ const mobile = settings.value.FOOTER_LOGO_WIDTH_MOBILE || desktop;
102
+ return {
103
+ "--osi-footer-logo-w": `${desktop}px`,
104
+ "--osi-footer-logo-w-mobile": `${mobile}px`,
105
+ };
106
+ });
107
+
108
+ const showBrandDescription = computed(
109
+ () => settings.value.FOOTER_SHOW_BRAND_DESCRIPTION === true && !!t(settings.value.FOOTER_BRAND_DESCRIPTION)
110
+ );
111
+ // Independent from FOOTER_LOGO_ALIGNMENT: that one only exists for TOP_CENTER
112
+ // (it justifies the logo row), while the description renders in all three
113
+ // logo positions and needs its own alignment regardless of where the logo sits.
114
+ const DESCRIPTION_ALIGNMENTS = { LEFT: "left", CENTER: "center", RIGHT: "right" };
115
+ const brandDescriptionStyle = computed(() => ({
116
+ maxWidth: `${settings.value.FOOTER_BRAND_DESCRIPTION_WIDTH || 32}ch`,
117
+ textAlign: DESCRIPTION_ALIGNMENTS[settings.value.FOOTER_BRAND_DESCRIPTION_ALIGNMENT] || "left",
118
+ // Centers the box itself (not just the text inside it) so a short
119
+ // description doesn't stay pinned to the left edge of its column.
120
+ marginInline: settings.value.FOOTER_BRAND_DESCRIPTION_ALIGNMENT === "CENTER" ? "auto" : undefined,
121
+ }));
122
+
123
+ const footerText = computed(() => t(settings.value.FOOTER_TEXT));
124
+ const copyright = computed(
125
+ () =>
126
+ t(settings.value.FOOTER_COPYRIGHT) ||
127
+ `© ${new Date().getFullYear()} ${store.value?.name ?? ""}`
128
+ );
129
+
130
+ // Guarded rather than assumed: StoreSerializer was trimmed to identity/theme
131
+ // fields, so these may not be on the bootstrap payload. If they aren't,
132
+ // the relevant block renders nothing instead of throwing.
133
+ // Socials live in useFooterSocials + FooterSocials.vue. SiteFooter only needs
134
+ // the count and the placement, because those decide whether a column or a
135
+ // bottom-bar zone renders at all.
136
+ const {
137
+ socials,
138
+ enabled: socialsEnabled,
139
+ position: socialPosition,
140
+ } = useFooterSocials();
141
+
142
+ const paymentIcons = computed(() => store.value?.payment_icons ?? []);
143
+ const languages = computed(() => store.value?.languages ?? []);
144
+ const countries = computed(() => store.value?.countries ?? []);
145
+ const currentLanguage = computed(() => store.value?.language ?? languages.value[0]);
146
+ const currentCountry = computed(() => store.value?.country ?? countries.value[0]);
147
+
148
+ // All "show X" booleans below default to true in the schema, so only an
149
+ // explicit `false` should hide them — undefined (an unsaved setting) must
150
+ // still fall through to the schema default, not be treated as "off".
151
+ const showSocial = socialsEnabled;
152
+ const socialAlignment = computed(() => {
153
+ const map = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" };
154
+ return map[settings.value.FOOTER_SOCIAL_ALIGNMENT] ?? "flex-start";
155
+ });
156
+
157
+ const showNewsletter = computed(() => settings.value.FOOTER_SHOW_NEWSLETTER !== false);
158
+ const newsletterOnStart = computed(() => (settings.value.FOOTER_NEWSLETTER_POSITION || "START") === "START");
159
+ const newsletterStacked = computed(() => settings.value.FOOTER_NEWSLETTER_LAYOUT === "STACKED");
160
+ const newsletterAlignment = computed(() => (settings.value.FOOTER_NEWSLETTER_ALIGNMENT || "LEFT").toLowerCase());
161
+ const showConsent = computed(() => settings.value.FOOTER_NEWSLETTER_SHOW_CONSENT !== false);
162
+
163
+ // Local-only "did it submit" state — the schema's success message is just
164
+ // copy; wiring the form to a real subscribe endpoint is unrelated to this
165
+ // settings pass, so submitting just reveals the message as an acknowledgement.
166
+ const newsletterSubmitted = ref(false);
167
+ function handleNewsletterSubmit() {
168
+ newsletterSubmitted.value = true;
169
+ }
170
+ const newsletterSuccessMessage = computed(
171
+ () => t(settings.value.FOOTER_NEWSLETTER_SUCCESS_MESSAGE) || "Thanks for subscribing!"
172
+ );
173
+
174
+ const showPaymentIcons = computed(() => settings.value.FOOTER_SHOW_PAYMENT_ICONS !== false);
175
+ const showLanguageSelector = computed(() => settings.value.FOOTER_SHOW_LANGUAGE_SELECTOR !== false);
176
+ const showCountrySelector = computed(() => settings.value.FOOTER_SHOW_COUNTRY_SELECTOR !== false);
177
+
178
+ // --------------------------------------------------------------- containers
179
+
180
+ /**
181
+ * Inner: max-width and centring only.
182
+ *
183
+ * The side gutter belongs on the footer root, not here. useSectionShell puts
184
+ * paddingInline on the shell and maxWidth on the inner; the footer had both on
185
+ * the inner, which makes its usable width maxWidth minus twice the gutter while
186
+ * a section directly above it gets the full maxWidth. That 32px is the
187
+ * misalignment between the two.
188
+ *
189
+ * SECTION_WIDTHS comes from useSectionShell rather than a local copy — the
190
+ * footer's own map had NARROW at calc(page-width - 320px) against the sections'
191
+ * flat 720px, so the same enum value produced two different widths.
192
+ */
193
+ const containerStyle = computed(() => ({
194
+ maxWidth: SECTION_WIDTHS[settings.value.FOOTER_CONTAINER] ?? SECTION_WIDTHS.NORMAL,
195
+ marginInline: "auto",
196
+ }));
197
+
198
+ const footerStyle = computed(() => ({
199
+ ...scheme.value.style,
200
+ ...appearance.value,
201
+ // Side gutter on the root, matching useSectionShell's shellStyle.
202
+ paddingInlineStart: `${settings.value.FOOTER_CONTAINER_PADDING_X ?? 16}px`,
203
+ paddingInlineEnd: `${settings.value.FOOTER_CONTAINER_PADDING_X ?? 16}px`,
204
+ "--osi-footer-pt": `${settings.value.FOOTER_PADDING_TOP ?? 64}px`,
205
+ "--osi-footer-pb": `${settings.value.FOOTER_PADDING_BOTTOM ?? 32}px`,
206
+ // The bottom bar needs vertical padding independent of FOOTER_PADDING_BOTTOM:
207
+ // that one is the gap below the whole footer, and base.json sets it to 0
208
+ // while still wanting the copyright row to breathe.
209
+ "--osi-footer-bar-py": `${settings.value.FOOTER_BOTTOM_PADDING_Y ?? 16}px`,
210
+ // Drives .osi-footer-inner and .osi-footer-main, which used to hardcode
211
+ // gap: 2rem regardless of what a merchant set here.
212
+ "--osi-footer-section-gap": `${settings.value.FOOTER_SECTION_GAP ?? 32}px`,
213
+ "--osi-footer-col-gap": `${settings.value.FOOTER_COLUMN_GAP ?? 64}px`,
214
+ "--osi-footer-col-row-gap": `${settings.value.FOOTER_COLUMN_ROW_GAP ?? 16}px`,
215
+ "--osi-footer-link-gap": `${settings.value.FOOTER_LINK_GAP ?? 12}px`,
216
+ "--osi-footer-social-gap": `${settings.value.FOOTER_SOCIAL_GAP ?? 16}px`,
217
+ "--osi-footer-payment-icon-size": `${settings.value.FOOTER_PAYMENT_ICON_SIZE || 20}px`,
218
+ "--osi-footer-payment-gap": `${settings.value.FOOTER_PAYMENT_ICON_GAP ?? 8}px`,
219
+ ...logoWidthVars.value,
220
+ // The divider marks the boundary between page content and the footer's
221
+ // top content area. With .osi-footer-inner hidden (see hasFooterContent),
222
+ // that boundary doesn't exist — the border would just sit directly above
223
+ // the bottom bar instead, which isn't what FOOTER_SHOW_DIVIDER means.
224
+ borderTop: !hasFooterContent.value || settings.value.FOOTER_SHOW_DIVIDER === false
225
+ ? "none"
226
+ : pick("FOOTER_DIVIDER_COLOR")
227
+ ? `1px solid ${pick("FOOTER_DIVIDER_COLOR")}`
228
+ : undefined,
229
+ }));
230
+
231
+ const columnsAlignment = computed(() => {
232
+ const map = { START: "flex-start", CENTER: "center", END: "flex-end", SPREAD: "space-between" };
233
+ return map[settings.value.FOOTER_COLUMNS_ALIGNMENT] ?? "flex-end";
234
+ });
235
+ const columnsVerticalAlignment = computed(() => {
236
+ const map = { TOP: "flex-start", CENTER: "center", BOTTOM: "flex-end" };
237
+ return map[settings.value.FOOTER_COLUMNS_VERTICAL_ALIGNMENT] ?? "flex-start";
238
+ });
239
+ const columnTextAlignment = computed(() => (settings.value.FOOTER_COLUMN_TEXT_ALIGNMENT || "LEFT").toLowerCase());
240
+
241
+ const JUSTIFY = { START: "flex-start", CENTER: "center", END: "flex-end", SPREAD: "space-between" };
242
+ // One per line: SPREAD has nothing to spread, so it centres.
243
+ const ALIGN = { START: "flex-start", CENTER: "center", END: "flex-end", SPREAD: "center" };
244
+ const TEXT = { START: "start", CENTER: "center", END: "end", SPREAD: "center" };
245
+ const bottomAlignment = computed(() => JUSTIFY[settings.value.FOOTER_BOTTOM_ALIGNMENT] ?? "center");
246
+ const showBottomBarDivider = computed(() => settings.value.FOOTER_SHOW_BOTTOM_BAR_DIVIDER !== false);
247
+
248
+
249
+ /**
250
+ * Bottom bar arrangement.
251
+ *
252
+ * INLINE one flex row, distributed by FOOTER_BOTTOM_ALIGNMENT. What
253
+ * every existing store renders today, unchanged.
254
+ * THREE_ZONE copyright and powered-by at the start, policy links centred,
255
+ * socials at the end — the Shopify arrangement.
256
+ *
257
+ * INLINE stays the schema default so no saved theme changes appearance; the
258
+ * preset opts into THREE_ZONE.
259
+ */
260
+ const bottomThreeZone = computed(
261
+ () => String(settings.value.FOOTER_BOTTOM_LAYOUT ?? "INLINE").toUpperCase() === "THREE_ZONE"
262
+ );
263
+ // STACKED: each part on its own line, aligned by FOOTER_BOTTOM_ALIGNMENT.
264
+ const bottomStacked = computed(
265
+ () => String(settings.value.FOOTER_BOTTOM_LAYOUT ?? "").toUpperCase() === "STACKED"
266
+ );
267
+
268
+ const px = (v) => (v === "" || v == null || !Number.isFinite(Number(v)) ? undefined : `${Number(v)}px`);
269
+
270
+ /** The bar's own look (Utilities section) and its phone alignment. */
271
+ const barVars = computed(() => {
272
+ const s = settings.value;
273
+ const align = String(s.FOOTER_BOTTOM_ALIGNMENT || "CENTER").toUpperCase();
274
+ const mobile = String(s.FOOTER_BOTTOM_MOBILE_ALIGNMENT || "CENTER").toUpperCase();
275
+ return Object.fromEntries(Object.entries({
276
+ "--bar-gap": px(s.FOOTER_BOTTOM_GAP),
277
+ "--bar-fs": px(s.FOOTER_BOTTOM_TEXT_SIZE),
278
+ "--bar-color": s.FOOTER_BOTTOM_TEXT_COLOR || undefined,
279
+ // A chosen colour is shown as chosen, not faded.
280
+ "--bar-opacity": s.FOOTER_BOTTOM_TEXT_COLOR ? "1" : undefined,
281
+ "--bar-align": ALIGN[align],
282
+ "--bar-text": TEXT[align],
283
+ "--bar-m-justify": JUSTIFY[mobile],
284
+ "--bar-m-align": ALIGN[mobile],
285
+ "--bar-m-text": TEXT[mobile],
286
+ }).filter(([, v]) => v !== undefined));
287
+ });
288
+
289
+ /** Policy links block: its own spacing, separator, underline, size, colour. */
290
+ const policyClasses = computed(() => {
291
+ const s = settings.value;
292
+ return [
293
+ `pol-sep-${String(s.FOOTER_POLICY_SEPARATOR || "NONE").toLowerCase()}`,
294
+ `pol-underline-${String(s.FOOTER_POLICY_UNDERLINE || "HOVER").toLowerCase()}`,
295
+ { "pol-stack-mobile": s.FOOTER_POLICY_MOBILE_INLINE === false },
296
+ ];
297
+ });
298
+ const policyStyle = computed(() => {
299
+ const s = settings.value;
300
+ return Object.fromEntries(Object.entries({
301
+ "--pol-gap": px(s.FOOTER_POLICY_GAP),
302
+ "--pol-fs": px(s.FOOTER_POLICY_TEXT_SIZE),
303
+ "--pol-color": s.FOOTER_POLICY_COLOR || undefined,
304
+ order: isBar.value ? utilityBlocks.value.order?.policies : undefined,
305
+ }).filter(([, v]) => v !== undefined && v !== -1));
306
+ });
307
+ const copyrightStyle = computed(() => {
308
+ const s = settings.value;
309
+ return Object.fromEntries(Object.entries({
310
+ fontSize: px(s.FOOTER_COPYRIGHT_TEXT_SIZE),
311
+ color: s.FOOTER_COPYRIGHT_COLOR || undefined,
312
+ }).filter(([, v]) => v !== undefined));
313
+ });
314
+ /** Utilities: parts in block order. */
315
+ const orderStyle = (part) => {
316
+ const n = isBar.value ? utilityBlocks.value.order?.[part] : undefined;
317
+ return n === undefined || n < 0 ? undefined : { order: n };
318
+ };
319
+
320
+ /**
321
+ * The bottom bar is a sibling of .osi-footer-inner, not a child, so it doesn't
322
+ * inherit the container. It takes the same containerStyle so its content —
323
+ * and its top border — line up with the columns above rather than running the
324
+ * full width of the footer.
325
+ *
326
+ * Kept a sibling rather than moved inside .osi-footer-inner because that
327
+ * element owns FOOTER_PADDING_BOTTOM; putting the bar in it would place the
328
+ * footer's bottom padding above the copyright row instead of below it.
329
+ *
330
+ * justifyContent only applies to INLINE — the three-zone grid places its own
331
+ * children, so handing it a flex property would do nothing at best.
332
+ */
333
+ const barStyle = computed(() => ({
334
+ ...containerStyle.value,
335
+ ...barVars.value,
336
+ ...(bottomThreeZone.value || bottomStacked.value ? {} : { justifyContent: bottomAlignment.value }),
337
+ }));
338
+
339
+ const showPoweredBy = computed(() => settings.value.FOOTER_SHOW_POWERED_BY !== false);
340
+ const poweredByText = computed(() => t(settings.value.FOOTER_POWERED_BY_TEXT));
341
+ // Plain text between the copyright and the link — not part of the link.
342
+ const poweredByPrefix = computed(() => {
343
+ const v = settings.value.FOOTER_POWERED_BY_PREFIX;
344
+ return v === undefined || v === null ? ", " : String(v);
345
+ });
346
+ // Copyright block: space between the copyright and "powered by" (default 0).
347
+ const copyrightGap = computed(() => {
348
+ const n = Number(settings.value.FOOTER_COPYRIGHT_GAP);
349
+ return { columnGap: `${Number.isFinite(n) ? n : 0}px` };
350
+ });
351
+ const poweredByUrl = computed(() => settings.value.FOOTER_POWERED_BY_URL || "");
352
+
353
+ /**
354
+ * Policy links as four fixed label/URL pairs, not a menu.
355
+ *
356
+ * The footer columns above are menus because a merchant curates those and their
357
+ * length varies. Policy links are a short fixed set that every store has, and
358
+ * requiring a merchant to build a menu before their terms link appears is a
359
+ * setup step with no upside. Same reasoning as the three fixed column slots:
360
+ * theme settings has no repeatable field type.
361
+ *
362
+ * A slot with a label but no URL is dropped rather than rendered as dead text.
363
+ */
364
+ const POLICY_SLOTS = [1, 2, 3, 4];
365
+
366
+ const policyLinks = computed(() => {
367
+ // Utilities: the Policy links block is the switch.
368
+ if (!isBar.value && settings.value.FOOTER_SHOW_POLICY_LINKS === false) return [];
369
+ return POLICY_SLOTS.map((n) => ({
370
+ key: `policy-${n}`,
371
+ label: t(settings.value[`FOOTER_POLICY_${n}_LABEL`]),
372
+ url: settings.value[`FOOTER_POLICY_${n}_URL`] || "",
373
+ })).filter((link) => link.label && link.url);
374
+ });
375
+
376
+ /**
377
+ * Placement is FOOTER_SOCIAL_POSITION's decision, always.
378
+ *
379
+ * Nothing double-renders because the four positions are mutually exclusive —
380
+ * that's what makes an enum the right shape for this. An earlier version had
381
+ * THREE_ZONE seize the socials to avoid a double render, which prevented the
382
+ * duplicate but also silently discarded the merchant's OWN_COLUMN choice: two
383
+ * settings fighting over one decision, with the winner invisible in the panel.
384
+ *
385
+ * So in the three-zone bar the end zone carries the socials only when the
386
+ * merchant put them there. Otherwise it carries the payment icons alone, and
387
+ * the socials render wherever they were assigned.
388
+ */
389
+ // The Utilities section's Social media links block; the old fixed footer's
390
+ // "with the copyright" position.
391
+ const socialsInBar = computed(() =>
392
+ isBar.value
393
+ ? !!settings.value.FOOTER_UTIL_SOCIAL
394
+ : props.part === "all" && showSocial.value && socialPosition.value === "WITH_COPYRIGHT"
395
+ );
396
+ const socialsInColumns = computed(
397
+ () =>
398
+ showSocial.value &&
399
+ (socialPosition.value === "OWN_COLUMN" || socialPosition.value === "UNDER_MENU")
400
+ );
401
+ const socialsUnderNewsletter = computed(
402
+ () => showSocial.value && socialPosition.value === "UNDER_NEWSLETTER"
403
+ );
404
+
405
+ // FOOTER_PADDING_TOP/BOTTOM exist to space real content away from the edges
406
+ // of .osi-footer-inner. With the newsletter turned off, no menus assigned,
407
+ // no logo, no footer text, and no locale selector, that padding has nothing
408
+ // to space out — it just renders as blank area above the bottom bar. Gate
409
+ // the whole block on there being something to show, same as every other
410
+ // piece of the footer is already individually gated.
411
+ const hasFooterContent = computed(() =>
412
+ Boolean(
413
+ props.tree?.children?.length ||
414
+ (logoSrc.value && logoPosition.value === "TOP_CENTER") ||
415
+ showNewsletter.value ||
416
+ columns.value.length ||
417
+ socialsInColumns.value ||
418
+ (logoSrc.value && logoPosition.value !== "TOP_CENTER") ||
419
+ footerText.value ||
420
+ (showLanguageSelector.value && (currentLanguage.value || currentCountry.value))
421
+ )
422
+ );
423
+ // ------------------------------------------------------- mobile accordion
424
+ //
425
+ // Desktop is unaffected either way: the accordion state only changes which
426
+ // CSS rules apply under the mobile breakpoint (see <style>), and open state
427
+ // is tracked per column index rather than per menu handle so a column
428
+ // staying open/closed doesn't depend on which menu happens to be assigned to it.
429
+ const mobileAccordion = computed(() => settings.value.FOOTER_MOBILE_COLUMN_LAYOUT === "ACCORDION");
430
+ const openColumns = ref(
431
+ settings.value.FOOTER_MOBILE_ACCORDION_EXPAND_FIRST === true ? { 0: true } : {}
432
+ );
433
+ function toggleColumn(index) {
434
+ openColumns.value = { ...openColumns.value, [index]: !openColumns.value[index] };
435
+ }
436
+ function isColumnOpen(index) {
437
+ return !!openColumns.value[index];
438
+ }
439
+
440
+ // ---------------------------------------------------------- text styling
441
+ //
442
+ // Mirrors what text_style() emits in the schema (SIZE / SIZE_MOBILE / WEIGHT /
443
+ // COLOR / LINE_HEIGHT / LETTER_SPACING / FONT) into CSS custom properties, so
444
+ // the two typography sets (heading, link) share one mapping instead of two
445
+ // near-identical computeds. TRANSFORM is applied as an inline style rather
446
+ // than a CSS var so its default can differ per prefix (headings default to
447
+ // uppercase, links don't) without a third variable.
448
+
449
+ const FONT_WEIGHTS = { REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
450
+ const FONT_FAMILIES = { HEADING: "var(--osi-font-heading)", BODY: "var(--osi-font-body)" };
451
+ const TEXT_TRANSFORMS = {
452
+ NONE: "none",
453
+ UPPERCASE: "uppercase",
454
+ LOWERCASE: "lowercase",
455
+ CAPITALIZE: "capitalize",
456
+ };
457
+
458
+ function textStyleVars(prefix, defaultTransform) {
459
+ const s = settings.value;
460
+ return {
461
+ "--osi-fs": `${s[`${prefix}_SIZE`] ?? 14}px`,
462
+ "--osi-fs-mobile": `${s[`${prefix}_SIZE_MOBILE`] || s[`${prefix}_SIZE`] || 14}px`,
463
+ "--osi-fw": FONT_WEIGHTS[s[`${prefix}_WEIGHT`]] ?? 400,
464
+ "--osi-lh": `${(s[`${prefix}_LINE_HEIGHT`] ?? 130) / 100}`,
465
+ "--osi-ls": `${(s[`${prefix}_LETTER_SPACING`] ?? 0) / 100}em`,
466
+ "--osi-ff": FONT_FAMILIES[s[`${prefix}_FONT`]] ?? "inherit",
467
+ color: s[`${prefix}_COLOR`] || undefined,
468
+ textTransform: TEXT_TRANSFORMS[s[`${prefix}_TRANSFORM`]] ?? TEXT_TRANSFORMS[defaultTransform],
469
+ };
470
+ }
471
+
472
+ const headingTextStyle = computed(() => textStyleVars("FOOTER_HEADING", "UPPERCASE"));
473
+ const linkTextStyle = computed(() => textStyleVars("FOOTER_LINK", "NONE"));
474
+
475
+ return { isBar, utilityBlocks, settings, brand, pick, barScheme, scheme, appearance, store, storeLogo, mediaUrl, useMenuByHandle, t, menu1, menu2, menu3, columnWidthMode, columnFlexBasis, columns, logoSrc, logoPosition, logoAlignment, logoWidthVars, showBrandDescription, DESCRIPTION_ALIGNMENTS, brandDescriptionStyle, footerText, copyright, socials, socialsEnabled, socialPosition, paymentIcons, languages, countries, currentLanguage, currentCountry, showSocial, socialAlignment, showNewsletter, newsletterOnStart, newsletterStacked, newsletterAlignment, showConsent, newsletterSubmitted, handleNewsletterSubmit, newsletterSuccessMessage, showPaymentIcons, showLanguageSelector, showCountrySelector, containerStyle, footerStyle, columnsAlignment, columnsVerticalAlignment, columnTextAlignment, JUSTIFY, ALIGN, TEXT, bottomAlignment, showBottomBarDivider, bottomThreeZone, bottomStacked, px, barVars, policyClasses, policyStyle, copyrightStyle, orderStyle, barStyle, showPoweredBy, poweredByText, poweredByPrefix, copyrightGap, poweredByUrl, POLICY_SLOTS, policyLinks, socialsInBar, socialsInColumns, socialsUnderNewsletter, hasFooterContent, mobileAccordion, openColumns, toggleColumn, isColumnOpen, FONT_WEIGHTS, FONT_FAMILIES, TEXT_TRANSFORMS, textStyleVars, headingTextStyle, linkTextStyle };
476
+ }