@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,254 @@
1
+ /**
2
+ * COUNTDOWN — time remaining until a date.
3
+ *
4
+ * Four things make this section fiddly:
5
+ *
6
+ * 1. **Hydration.** A server-rendered "04:12:33" is stale by the time it
7
+ * reaches the browser, and Vue warns about the mismatch. So the server
8
+ * renders dashes and the real numbers appear on mount. That's one frame of
9
+ * placeholder, versus a console full of hydration errors and a clock that
10
+ * can be minutes wrong on a cached page.
11
+ * 2. **Timezone.** END_DATE is a naive datetime from the dashboard; the shopper
12
+ * is in their own zone. TIMEZONE_MODE says which is meant — STORE treats it
13
+ * as a fixed instant via an explicit offset, VISITOR as local wall-clock.
14
+ * Getting this wrong ends a sale an hour early for half the world.
15
+ * 3. **Settings drift.** Different callers of this section have shipped data
16
+ * with END_DATE/ENDS_AT and BUTTON_LABEL/CTA_LABEL interchangeably. A
17
+ * missing key here doesn't error — it just quietly hides the whole
18
+ * section (expired-with-no-target -> ON_EXPIRE=HIDE). So setting
19
+ * resolution below accepts both spellings rather than trusting one.
20
+ * 4. **Box styling is fully merchant-controlled**, including color-scheme
21
+ * fallbacks. Every box/value/label style is computed once and applied via
22
+ * :style rather than scoped classes, so an empty setting cleanly falls
23
+ * back to the store's color scheme instead of leaving a blank value.
24
+ *
25
+ * Settings: HEADING, SUBHEADING, END_DATE (alias: ENDS_AT), TIMEZONE_MODE
26
+ * (STORE|VISITOR), STORE_UTC_OFFSET, ON_EXPIRE (HIDE|MESSAGE|KEEP),
27
+ * EXPIRED_MESSAGE, SHOW_DAYS, SHOW_LABELS, BUTTON_LABEL (alias:
28
+ * CTA_LABEL), BUTTON_URL (alias: CTA_URL), DIGIT_STYLE
29
+ * (BOX|SOLID|MINIMAL), URGENT_MINUTES (default 60, 0 disables),
30
+ * BOX_BACKGROUND, BOX_BORDER_COLOR, BOX_BORDER_WIDTH, BOX_RADIUS,
31
+ * BOX_PADDING, UNIT_GAP, VALUE_SIZE, VALUE_COLOR, LABEL_COLOR,
32
+ * LABEL_SIZE, URGENT_BACKGROUND, URGENT_VALUE_COLOR,
33
+ * HEADING_TEXT_*, SUBHEADING_TEXT_* (size/weight/color/line-height/
34
+ * letter-spacing/font, per text_style()), CTA_VARIANT
35
+ * (PRIMARY|SECONDARY|LINK), CTA_BACKGROUND, CTA_TEXT_COLOR,
36
+ * CTA_RADIUS, CTA_FULL_WIDTH
37
+ *
38
+ * Note: the button only renders when BOTH a label and a URL resolve — a
39
+ * label with no destination isn't shown rather than rendering a dead link.
40
+ */
41
+
42
+
43
+ export default function useLogic({ props }) {
44
+ const { s, t, shellStyle, innerStyle, align, customClass, heading } =
45
+ useSectionShell(() => props.section);
46
+
47
+ // Defensive aliasing — different section-data producers have used different
48
+ // key spellings for the same field. Prefer the canonical key, fall back to
49
+ // the alternates seen in the wild.
50
+ const rawEndDate = computed(() => s.value.END_DATE ?? s.value.ENDS_AT ?? s.value.END_AT);
51
+ const rawButtonLabel = computed(() => s.value.BUTTON_LABEL ?? s.value.CTA_LABEL);
52
+ const rawButtonUrl = computed(() => s.value.BUTTON_URL ?? s.value.CTA_URL);
53
+
54
+ /**
55
+ * No end date yet: in the editor, count down to a week from now so the
56
+ * merchant sees a working timer while designing (the schema also pre-fills a
57
+ * date a week out). On the live store a missing date shows nothing, rather
58
+ * than a made-up deadline.
59
+ */
60
+ const placeholders = usePlaceholders();
61
+ const demoTarget = new Date(Date.now() + 7 * 86400 * 1000);
62
+ const isDemo = computed(() => !rawEndDate.value && !!placeholders.allowed?.value);
63
+
64
+ const target = computed(() => {
65
+ const raw = rawEndDate.value;
66
+ if (!raw) return isDemo.value ? demoTarget : null;
67
+
68
+ if (s.value.TIMEZONE_MODE === "STORE") {
69
+ const offset = s.value.STORE_UTC_OFFSET ?? "+00:00";
70
+ // Only append an offset when the string doesn't already carry one.
71
+ const hasZone = /([+-]\d{2}:?\d{2}|Z)$/.test(raw);
72
+ const value = new Date(hasZone ? raw : `${raw}${offset}`);
73
+ return Number.isNaN(value.getTime()) ? null : value;
74
+ }
75
+
76
+ const value = new Date(raw);
77
+ return Number.isNaN(value.getTime()) ? null : value;
78
+ });
79
+
80
+ const remaining = ref(null); // null until mounted — see the hydration note
81
+ let timer = null;
82
+
83
+ function tick() {
84
+ if (!target.value) {
85
+ remaining.value = 0;
86
+ return;
87
+ }
88
+ remaining.value = Math.max(0, target.value.getTime() - Date.now());
89
+ }
90
+
91
+ onMounted(() => {
92
+ tick();
93
+ timer = setInterval(tick, 1000);
94
+ });
95
+ onBeforeUnmount(() => clearInterval(timer));
96
+
97
+ // Recompute immediately when the merchant edits the date in the builder.
98
+ watch(target, () => {
99
+ if (import.meta.client) tick();
100
+ });
101
+
102
+ const parts = computed(() => {
103
+ if (remaining.value === null) return null;
104
+
105
+ const total = Math.floor(remaining.value / 1000);
106
+ const showDays = s.value.SHOW_DAYS !== false;
107
+
108
+ const days = showDays ? Math.floor(total / 86400) : 0;
109
+ // Hours absorb the days when the days unit is hidden, so a 3-day sale doesn't
110
+ // show as "0 hours".
111
+ const hours = Math.floor((total - days * 86400) / 3600);
112
+
113
+ return [
114
+ ...(showDays ? [{ id: "d", value: days, label: "Days" }] : []),
115
+ { id: "h", value: hours, label: "Hours" },
116
+ { id: "m", value: Math.floor((total % 3600) / 60), label: "Minutes" },
117
+ { id: "s", value: total % 60, label: "Seconds" },
118
+ ];
119
+ });
120
+
121
+ const expired = computed(() => remaining.value !== null && remaining.value <= 0);
122
+ // Default MESSAGE, not HIDE: a section that silently vanishes when its date
123
+ // passes (or was never set) reads as "the countdown is broken".
124
+ const onExpire = computed(() => s.value.ON_EXPIRE ?? "MESSAGE");
125
+ const hidden = computed(
126
+ () => (!target.value && !isDemo.value) || (expired.value && onExpire.value === "HIDE")
127
+ );
128
+ const showZeros = computed(() => !expired.value || onExpire.value === "KEEP");
129
+
130
+ // Urgency: the closing stretch gets a distinct, merchant-defined visual
131
+ // state rather than looking identical to a countdown with three days left.
132
+ const urgentMinutes = computed(() => {
133
+ const raw = s.value.URGENT_MINUTES;
134
+ return raw === 0 ? 0 : raw ?? 60;
135
+ });
136
+ const isUrgent = computed(() => {
137
+ if (!urgentMinutes.value || remaining.value === null || expired.value) return false;
138
+ return remaining.value <= urgentMinutes.value * 60 * 1000;
139
+ });
140
+
141
+ const pad = (value) => String(value).padStart(2, "0");
142
+
143
+ const buttonLabel = computed(() => t(rawButtonLabel.value));
144
+ const buttonUrl = computed(() => rawButtonUrl.value || null);
145
+ const digitStyle = computed(() => (s.value.DIGIT_STYLE || "BOX").toLowerCase());
146
+
147
+ // --- Fully merchant-controlled box styling -------------------------------
148
+ // Every value here has a sensible fallback to the theme's color-scheme
149
+ // variables, so an untouched section still looks intentional out of the box.
150
+
151
+ const px = (value, fallback) => `${value ?? fallback}px`;
152
+
153
+ const unitsStyle = computed(() => ({
154
+ gap: px(s.value.UNIT_GAP, 12),
155
+ }));
156
+
157
+ const boxStyle = computed(() => {
158
+ const style = digitStyle.value === "minimal" ? {} : {};
159
+
160
+ // Background: SOLID defaults to the theme button color; BOX defaults to
161
+ // transparent (just a border); MINIMAL never gets a background.
162
+ if (digitStyle.value !== "minimal") {
163
+ const defaultBg = digitStyle.value === "solid" ? "var(--osi-btn-bg)" : "transparent";
164
+ style.background = isUrgent.value
165
+ ? s.value.URGENT_BACKGROUND || s.value.BOX_BACKGROUND || defaultBg
166
+ : s.value.BOX_BACKGROUND || defaultBg;
167
+ style.borderRadius = px(s.value.BOX_RADIUS, 4);
168
+ style.padding = px(s.value.BOX_PADDING, 12);
169
+ }
170
+
171
+ if (digitStyle.value === "box") {
172
+ style.borderWidth = px(s.value.BOX_BORDER_WIDTH, 1);
173
+ style.borderStyle = "solid";
174
+ style.borderColor = s.value.BOX_BORDER_COLOR || "var(--osi-border)";
175
+ }
176
+
177
+ return style;
178
+ });
179
+
180
+ const valueStyle = computed(() => {
181
+ const defaultColor = digitStyle.value === "solid" ? "var(--osi-btn-text)" : "inherit";
182
+ return {
183
+ fontSize: px(s.value.VALUE_SIZE, 44),
184
+ color: isUrgent.value
185
+ ? s.value.URGENT_VALUE_COLOR || s.value.VALUE_COLOR || defaultColor
186
+ : s.value.VALUE_COLOR || defaultColor,
187
+ };
188
+ });
189
+
190
+ const labelStyle = computed(() => ({
191
+ fontSize: px(s.value.LABEL_SIZE, 11),
192
+ color: s.value.LABEL_COLOR || undefined,
193
+ }));
194
+
195
+ // --- CTA button styling ---------------------------------------------------
196
+ // Variant sets the base look; CTA_BACKGROUND/CTA_TEXT_COLOR/CTA_RADIUS layer
197
+ // on top as explicit overrides, same override-over-default pattern as the
198
+ // box styling above.
199
+
200
+ const ctaVariant = computed(() => s.value.CTA_VARIANT || "PRIMARY");
201
+
202
+ // CTA_* are this section's own button settings; anything left empty follows
203
+ // the theme's Buttons group via .osi-btn.
204
+ const ctaStyle = computed(() => {
205
+ const style = {};
206
+ if (s.value.CTA_BACKGROUND) style["--btn-bg"] = s.value.CTA_BACKGROUND;
207
+ if (s.value.CTA_TEXT_COLOR) style["--btn-fg"] = s.value.CTA_TEXT_COLOR;
208
+ if (s.value.CTA_RADIUS !== undefined && s.value.CTA_RADIUS !== null && s.value.CTA_RADIUS !== "") {
209
+ style["--btn-radius"] = `${s.value.CTA_RADIUS}px`;
210
+ }
211
+ return style;
212
+ });
213
+
214
+ const ctaClasses = computed(() => [
215
+ "osi-btn",
216
+ "osi-cd-button",
217
+ {
218
+ PRIMARY: "",
219
+ // SECONDARY = outline in the CTA's own colour when set, else the
220
+ // theme's secondary colours.
221
+ SECONDARY: s.value.CTA_BACKGROUND ? "osi-btn--outline" : "osi-btn--secondary",
222
+ LINK: "osi-btn--underline",
223
+ }[ctaVariant.value] ?? "",
224
+ { "osi-btn--block": !!s.value.CTA_FULL_WIDTH },
225
+ ]);
226
+
227
+ // --- Vertical spacing between blocks --------------------------------------
228
+ // Previously a single hardcoded `gap: 1rem` on the flex column applied the
229
+ // same spacing everywhere. These give heading->subheading, text->countdown,
230
+ // and countdown->button independent control. Only applied when the element
231
+ // above actually rendered, so an untouched HEADING doesn't leave a phantom
232
+ // gap at the top when no button is shown, etc.
233
+
234
+ const hasHeading = computed(() => !!heading.value);
235
+ const hasSubheading = computed(() => !!t(s.value.SUBHEADING));
236
+ const hasTextBlock = computed(() => hasHeading.value || hasSubheading.value);
237
+
238
+ const subheadingStyle = computed(() =>
239
+ hasHeading.value ? { marginTop: px(s.value.HEADING_GAP, 12) } : {}
240
+ );
241
+ const countdownBlockStyle = computed(() =>
242
+ hasTextBlock.value ? { marginTop: px(s.value.CONTENT_GAP, 20) } : {}
243
+ );
244
+ const buttonStyle_spacing = computed(() => ({ marginTop: px(s.value.BUTTON_GAP, 20) }));
245
+
246
+ // A once-per-minute plain-language string for screen readers, instead of
247
+ // re-announcing every second like the visual timer does.
248
+ const srText = computed(() => {
249
+ if (!parts.value) return "";
250
+ return parts.value.map((p) => `${p.value} ${p.label}`).join(", ") + " remaining";
251
+ });
252
+
253
+ return { s, t, shellStyle, innerStyle, align, customClass, heading, rawEndDate, rawButtonLabel, rawButtonUrl, placeholders, demoTarget, isDemo, target, remaining, timer, tick, parts, expired, onExpire, hidden, showZeros, urgentMinutes, isUrgent, pad, buttonLabel, buttonUrl, digitStyle, px, unitsStyle, boxStyle, valueStyle, labelStyle, ctaVariant, ctaStyle, ctaClasses, hasHeading, hasSubheading, hasTextBlock, subheadingStyle, countdownBlockStyle, buttonStyle_spacing, srText };
254
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * CUSTOM_HTML — merchant-authored markup, with optional scoped CSS.
3
+ *
4
+ * ── What v-html does and doesn't do ────────────────────────────────────────
5
+ *
6
+ * Inline <script> tags in v-html do NOT execute — the browser ignores scripts
7
+ * inserted via innerHTML. Event-handler attributes (onclick, onerror, onload)
8
+ * DO fire, and so do <iframe>, <object> and <embed>. So this is not a sandbox.
9
+ *
10
+ * That's an accepted risk here, at the same trust level as RICH_TEXT's BODY:
11
+ * the author is the store owner, editing their own storefront, and the payload
12
+ * only reaches this component through an authenticated dashboard write. If
13
+ * section content ever becomes writable by anyone less trusted — a staff role
14
+ * with limited permissions, an app integration, an import — this needs
15
+ * server-side sanitisation before that ships, not after.
16
+ *
17
+ * What is enforced: on SSR the markup renders as-is, but a merchant pasting a
18
+ * full page (<html>, <head>, <body>) would nest document tags inside a
19
+ * <section>, which browsers silently discard — so those wrappers are stripped
20
+ * and the contents kept, rather than the section rendering blank with no
21
+ * explanation.
22
+ *
23
+ * Settings: HTML, CUSTOM_CSS, FULL_WIDTH
24
+ */
25
+
26
+
27
+ export default function useLogic({ props }) {
28
+ const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(
29
+ () => props.section
30
+ );
31
+
32
+ const html = computed(() => {
33
+ const raw = t(s.value.HTML) || "";
34
+ if (!raw) return "";
35
+
36
+ return raw
37
+ .replace(/<\/?(?:html|head|body)\b[^>]*>/gi, "")
38
+ // <title> and <meta> inside the body are inert and confuse the outline.
39
+ .replace(/<title\b[^>]*>[\s\S]*?<\/title>/gi, "")
40
+ .replace(/<meta\b[^>]*>/gi, "");
41
+ });
42
+
43
+ // Scoped per section instance so two CUSTOM_HTML blocks on one page can't
44
+ // overwrite each other's rules.
45
+ const scopeAttr = computed(() => `[data-osi-section="${props.section.key}"]`);
46
+ useScopedCss(() => s.value.CUSTOM_CSS, scopeAttr, `osi-html-css-${props.section.key}`);
47
+
48
+ const fullWidth = computed(() => !!s.value.FULL_WIDTH);
49
+
50
+ return { s, t, shellStyle, innerStyle, customClass, html, scopeAttr, fullWidth };
51
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * DIVIDER — a visible line between sections.
3
+ *
4
+ * Spacing above and below is the section's own padding (common settings,
5
+ * with phone values), so a divider can hug the sections around it or stand
6
+ * apart. SPACER is the empty-space sibling.
7
+ *
8
+ * Settings: DIVIDER_STYLE (SOLID | DASHED | DOTTED | DOUBLE), DIVIDER_THICKNESS,
9
+ * DIVIDER_WIDTH (%), DIVIDER_COLOR, DIVIDER_ALIGNMENT, DIVIDER_LABEL
10
+ * (optional text in the middle of the line)
11
+ */
12
+
13
+
14
+ export default function useLogic({ props }) {
15
+ const { s, t, shellStyle, innerStyle, customClass } = useSectionShell(() => props.section);
16
+
17
+ const has = (v) => v !== undefined && v !== null && v !== "";
18
+ const label = computed(() => t(s.value.DIVIDER_LABEL));
19
+
20
+ const vars = computed(() => ({
21
+ "--dv-style": String(s.value.DIVIDER_STYLE || "SOLID").toLowerCase(),
22
+ "--dv-w": `${has(s.value.DIVIDER_THICKNESS) ? Number(s.value.DIVIDER_THICKNESS) : 1}px`,
23
+ "--dv-width": `${has(s.value.DIVIDER_WIDTH) ? Number(s.value.DIVIDER_WIDTH) : 100}%`,
24
+ "--dv-color": s.value.DIVIDER_COLOR || "var(--osi-border, #e5e5e5)",
25
+ "--dv-justify":
26
+ { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end" }[String(s.value.DIVIDER_ALIGNMENT || "CENTER").toUpperCase()] ??
27
+ "center",
28
+ }));
29
+
30
+ return { s, t, shellStyle, innerStyle, customClass, has, label, vars };
31
+ }
@@ -0,0 +1,53 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "LEFT", POSITION: "TOP", GAP: 24 };
4
+
5
+ const { t } = useSectionShell(() => props.section);
6
+
7
+ const structuredData = computed(() => {
8
+ const blocks = props.section.blocks ?? [];
9
+ const byKey = new Map(blocks.map((b) => [b.key, b]));
10
+
11
+ // Live only if the block and every group above it are enabled and present.
12
+ const live = (block, depth = 0) => {
13
+ if (!block || block.disabled || depth > 16) return false;
14
+ if (!block.parent_key) return true;
15
+ return live(byKey.get(block.parent_key), depth + 1);
16
+ };
17
+
18
+ const entries = blocks
19
+ .filter((b) => b.type === "FAQ_ITEM" && live(b))
20
+ .filter((b) => {
21
+ const accordion = byKey.get(b.parent_key);
22
+ return accordion?.type === "ACCORDION" && accordion.settings?.FAQ_SCHEMA !== false;
23
+ })
24
+ .map((b) => ({ question: t(b.settings?.QUESTION), answer: t(b.settings?.ANSWER) }))
25
+ .filter((e) => e.question && e.answer);
26
+
27
+ if (!entries.length) return null;
28
+ return {
29
+ "@context": "https://schema.org",
30
+ "@type": "FAQPage",
31
+ mainEntity: entries.map((e) => ({
32
+ "@type": "Question",
33
+ name: e.question,
34
+ acceptedAnswer: { "@type": "Answer", text: e.answer },
35
+ })),
36
+ };
37
+ });
38
+
39
+ useHead(() => ({
40
+ script: structuredData.value
41
+ ? [
42
+ {
43
+ key: `faq-ld-${props.section.key}`,
44
+ type: "application/ld+json",
45
+ // "</" can't appear inside a script element's text.
46
+ innerHTML: JSON.stringify(structuredData.value).replace(/</g, "\\u003c"),
47
+ },
48
+ ]
49
+ : [],
50
+ }));
51
+
52
+ return { ROOT_DEFAULTS, t, structuredData };
53
+ }
@@ -0,0 +1,6 @@
1
+
2
+ export default function useLogic() {
3
+ const ROOT_DEFAULTS = { DIRECTION: "VERTICAL", ALIGNMENT: "CENTER", POSITION: "TOP", GAP: 32 };
4
+
5
+ return { ROOT_DEFAULTS };
6
+ }
@@ -0,0 +1,40 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ const DEFAULT_BLOCKS = [
4
+ { id: "fp-title", type: "TITLE", settings: {} },
5
+ { id: "fp-price", type: "PRICE", settings: {} },
6
+ { id: "fp-picker", type: "VARIANT_PICKER", settings: {} },
7
+ { id: "fp-buy", type: "BUY_BUTTONS", settings: { BUTTON_HEIGHT: 44 } },
8
+ { id: "fp-description", type: "DESCRIPTION", settings: { DESCRIPTION_COLLAPSE: true, DESCRIPTION_CLAMP: 3 } },
9
+ {
10
+ id: "fp-more",
11
+ type: "BUTTON",
12
+ settings: { LABEL: "View full details", LINK_TO_PRODUCT: true, BUTTON_STYLE: "UNDERLINE" },
13
+ },
14
+ ];
15
+
16
+ /**
17
+ * The gallery is always a carousel here, desktop and phones (the section's
18
+ * schema has no layout picker). Unless the merchant set them: image and
19
+ * details share the row 50 / 50, the image square, the details filling their
20
+ * column. Side by side from tablets up (`featured` below), stacked on phones.
21
+ */
22
+ const FIXED = { MEDIA_LAYOUT: "CAROUSEL", MOBILE_MEDIA_LAYOUT: "CAROUSEL" };
23
+ const SMALLER = { MEDIA_WIDTH: 50, MEDIA_RATIO: "SQUARE" };
24
+ const blank = (v) => v === undefined || v === null || v === "";
25
+
26
+ const withBlocks = computed(() => {
27
+ const settings = { ...(props.section.settings ?? {}) };
28
+ for (const [key, value] of Object.entries(SMALLER)) if (blank(settings[key])) settings[key] = value;
29
+ Object.assign(settings, FIXED);
30
+ return {
31
+ ...props.section,
32
+ settings,
33
+ blocks: (props.section.blocks ?? []).length ? props.section.blocks : DEFAULT_BLOCKS,
34
+ };
35
+ });
36
+
37
+ const productId = computed(() => props.section.settings?.PRODUCT ?? props.section.data?.product ?? null);
38
+
39
+ return { DEFAULT_BLOCKS, FIXED, SMALLER, blank, withBlocks, productId };
40
+ }
@@ -0,0 +1,16 @@
1
+ import { FooterBlock } from "#components";
2
+
3
+ export default function useLogic({ props }) {
4
+ const { tree } = useContentTree(() => props.section, {
5
+ rootDefaults: { DIRECTION: "HORIZONTAL", ALIGNMENT: "SPACE_BETWEEN", POSITION: "TOP", GAP: 40 },
6
+ custom: {
7
+ FOOTER_MENU: FooterBlock,
8
+ FOOTER_LOGO: FooterBlock,
9
+ FOOTER_NEWSLETTER: FooterBlock,
10
+ FOOTER_SOCIAL: FooterBlock,
11
+ },
12
+ index: () => props.index,
13
+ });
14
+
15
+ return { tree };
16
+ }
@@ -0,0 +1,24 @@
1
+
2
+ export default function useLogic({ props }) {
3
+ // The blocks' keys, so SiteFooter can tag their parts for the editor.
4
+ const keyOf = (type) =>
5
+ (props.section.blocks ?? []).find((b) => b.type === type && !b.disabled)?.key ?? null;
6
+ // …and their places: the bar shows its parts in block order (drag to reorder).
7
+ const orderOf = (type) =>
8
+ (props.section.blocks ?? []).filter((b) => !b.disabled).findIndex((b) => b.type === type);
9
+ provide(
10
+ "osiUtilityBlocks",
11
+ computed(() => ({
12
+ copyright: keyOf("UTILITY_COPYRIGHT"),
13
+ policies: keyOf("UTILITY_POLICIES"),
14
+ social: keyOf("UTILITY_SOCIAL"),
15
+ order: {
16
+ copyright: orderOf("UTILITY_COPYRIGHT"),
17
+ policies: orderOf("UTILITY_POLICIES"),
18
+ social: orderOf("UTILITY_SOCIAL"),
19
+ },
20
+ }))
21
+ );
22
+
23
+ return { keyOf, orderOf };
24
+ }