@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,46 @@
1
+ <script setup>
2
+ import { resolveSection } from "#osi/theme/registry";
3
+
4
+ /**
5
+ * Renders a template's sections in order.
6
+ *
7
+ * data-osi-section / data-osi-block are emitted in production too, not just in
8
+ * preview. They cost nothing, and forgetting them on one section type is an
9
+ * infuriating bug to chase later — click-to-select silently stops working for
10
+ * that section only.
11
+ */
12
+ defineProps({
13
+ sections: { type: Array, default: () => [] },
14
+ });
15
+
16
+ // Hover / selection outlines are drawn by BuilderOverlay (pages/__builder-preview),
17
+ // not here: a CSS outline gets clipped by overflow, can't carry a name tag or
18
+ // buttons, and a nested block's outline would fight its section's.
19
+ const { isPreview } = useBuilderPreview();
20
+ </script>
21
+
22
+ <template>
23
+ <div class="osi-sections">
24
+ <template v-for="(section, i) in sections" :key="section.key">
25
+ <component
26
+ :is="resolveSection(section.type)"
27
+ v-if="resolveSection(section.type)"
28
+ :section="section"
29
+ :index="i"
30
+ :data-osi-section="section.key"
31
+ :class="{ 'osi-preview-disabled': isPreview && section.disabled }"
32
+ />
33
+ </template>
34
+ </div>
35
+ </template>
36
+
37
+ <style scoped>
38
+ .osi-sections > :deep(*) + :deep(*) {
39
+ margin-top: 0;
40
+ }
41
+
42
+ /* Editor only — never rendered on a live storefront. */
43
+ .osi-preview-disabled {
44
+ opacity: 0.45;
45
+ }
46
+ </style>
@@ -0,0 +1,582 @@
1
+ <script setup>
2
+ import {
3
+ findPreviewElements,
4
+ previewElements,
5
+ previewSelector,
6
+ resolvePreviewElement,
7
+ } from "#osi/composables/useBuilderPreview";
8
+
9
+ /**
10
+ * The editor's overlay inside the builder preview — Shopify-style.
11
+ *
12
+ * hover thin blue outline + a name tag with the block's parents
13
+ * ("Rich text › Group › Text"), faint dashed outlines on those
14
+ * parents so the nesting shows
15
+ * copies a block drawn more than once (a card part, once per item in a
16
+ * list) gets its box on the copy under the pointer / the one
17
+ * clicked, and a light border on every other copy — so it reads
18
+ * "this, and all of these".
19
+ * selected 2px blue border, the same tag with clickable crumbs (select a
20
+ * parent), and a toolbar: move up / down, duplicate, delete
21
+ * "+" on the hovered (else selected) block, on the two edges along
22
+ * its parent's direction — insert before / after; centred in an
23
+ * empty group; "Add section" above / below a section
24
+ *
25
+ * One fixed layer over the page, never an outline on the blocks themselves:
26
+ * outlines get clipped by overflow, can't carry a tag or buttons, and a nested
27
+ * block's outline would fight its parents'. The layer ignores the pointer
28
+ * except on its own buttons ([data-osi-overlay] — useBuilderPreview skips
29
+ * those when tracking hover and clicks).
30
+ *
31
+ * Teleported to <body>, so no wrapper's stacking context can put the site's
32
+ * sticky header above it; name tags also stick just below such a header while
33
+ * their block is on screen (topInset / tagStyle).
34
+ *
35
+ * The dashboard clips this iframe to a rounded shell (PreviewFrame.vue). An
36
+ * outline that reaches the top / bottom of the frame rounds its corners to
37
+ * follow that curve (see boxStyle) instead of being cut off square. Name tags
38
+ * are left exactly as they are. The radius arrives in a FRAME message — see
39
+ * frameRadius.
40
+ *
41
+ * Positions come from getBoundingClientRect() in a requestAnimationFrame loop,
42
+ * so boxes follow scrolling, carousels, the marquee and re-renders without a
43
+ * listener per case. The loop only touches reactive state when a box moved.
44
+ *
45
+ * Hidden entirely while the dashboard's top-bar toggle is off (overlayOn).
46
+ *
47
+ * Talks to the dashboard through useBuilderPreview().send():
48
+ * SECTION_CLICK { key, blockKey } select (crumbs)
49
+ * ACTION { kind, key, action } up | down | duplicate | delete
50
+ * ADD_BLOCK { section, parent, index, x, y }
51
+ * ADD_SECTION { index, area } area: header | template | footer
52
+ */
53
+ const { state, selectedKey, hoveredKey, labels, overlayOn, send, isPreview } = useBuilderPreview();
54
+
55
+ /* ------------------------------------------------------------------ tree */
56
+
57
+ /**
58
+ * Every section on the page: the header group's, the template's, the footer
59
+ * group's — each tagged with its area, positions counted within it.
60
+ */
61
+ const allSections = computed(() => {
62
+ const s = state.value ?? {};
63
+ const tag = (list, area) => (list ?? []).map((section, position) => ({ section, area, position }));
64
+ return [
65
+ ...tag(s.groups?.header, "header"),
66
+ ...tag(s.sections, "template"),
67
+ ...tag(s.groups?.footer, "footer"),
68
+ ];
69
+ });
70
+
71
+ /** key -> { kind, section, parent, type } for every section and block. */
72
+ const index = computed(() => {
73
+ const map = {};
74
+ allSections.value.forEach(({ section, area, position }) => {
75
+ map[section.key] = { kind: "SECTION", section: section.key, parent: null, type: section.type, position, area };
76
+ for (const block of section.blocks ?? []) {
77
+ map[block.key] = { kind: "BLOCK", section: section.key, parent: block.parent_key || null, type: block.type };
78
+ }
79
+ });
80
+ return map;
81
+ });
82
+
83
+ function sectionOf(key) {
84
+ return allSections.value.find((e) => e.section.key === index.value[key]?.section)?.section;
85
+ }
86
+
87
+ /** Keys of `parent`'s children (null = the section itself), in order. */
88
+ function siblings(sectionKey, parent) {
89
+ const section = allSections.value.find((e) => e.section.key === sectionKey)?.section;
90
+ return (section?.blocks ?? []).filter((b) => (b.parent_key || null) === parent).map((b) => b.key);
91
+ }
92
+
93
+ /** [section, ...parents, key] — outermost first. */
94
+ function chain(key) {
95
+ const info = index.value[key];
96
+ if (!info) return [];
97
+ if (info.kind === "SECTION") return [key];
98
+ const out = [key];
99
+ for (let p = info.parent, guard = 0; p && guard < 20; p = index.value[p]?.parent, guard++) out.unshift(p);
100
+ out.unshift(info.section);
101
+ return out;
102
+ }
103
+
104
+ function nameOf(key) {
105
+ const label = labels.value?.[key]?.label;
106
+ if (label) return label;
107
+ const type = index.value[key]?.type ?? "";
108
+ return type.charAt(0) + type.slice(1).toLowerCase().replace(/_/g, " ");
109
+ }
110
+ const hintOf = (key) => labels.value?.[key]?.hint || "";
111
+ const isContainer = (key) => !!labels.value?.[key]?.container;
112
+ // Static blocks (a static_blocks section): no move / duplicate / delete / "+".
113
+ const isFixed = (key) => !!labels.value?.[key]?.fixed;
114
+ // The page's main section (schema `required`): no delete.
115
+ const isRequired = (key) => !!labels.value?.[key]?.required;
116
+
117
+ /* --------------------------------------------------------------- measuring */
118
+
119
+ const view = reactive({
120
+ hover: null, // { key, top, left, width, height, row }
121
+ selected: null,
122
+ parents: [], // rects of the hovered block's parents
123
+ // Other copies of the hovered / selected block (a card part is drawn once
124
+ // per item): a light border on each, so all of them read as one block.
125
+ twins: [],
126
+ // Bottom edge of a fixed / sticky header pinned to the top of the page, if
127
+ // any: name tags stick just below it instead of sliding underneath.
128
+ safeTop: 0,
129
+ });
130
+
131
+ /**
132
+ * How much of the top of the viewport a pinned header covers. Probes what's
133
+ * at the top centre (the overlay itself doesn't count — it ignores the
134
+ * pointer, so hit-testing skips it) and walks up to a fixed / sticky
135
+ * ancestor touching the top. No such header -> 0.
136
+ */
137
+ function topInset() {
138
+ const toolbar = document.querySelector("[data-osi-toolbar]"); // however your toolbar is marked
139
+ if (!toolbar) return 0;
140
+ const r = toolbar.getBoundingClientRect();
141
+ return r.top <= 1 ? r.bottom : 0;
142
+ }
143
+
144
+ /** The on-screen box of one element, standing for `key`. */
145
+ function rectOf(el, key, id = key) {
146
+ if (!el) return null;
147
+ const r = el.getBoundingClientRect();
148
+ if (!r.width && !r.height) return null;
149
+ // Which way its siblings run: the "+" buttons sit on the edges along it.
150
+ const parent = el.parentElement;
151
+ const cs = parent ? getComputedStyle(parent) : null;
152
+ const row = !!cs && cs.display.includes("flex") && cs.flexDirection.startsWith("row");
153
+ return { key, id, top: r.top, left: r.left, width: r.width, height: r.height, row };
154
+ }
155
+
156
+ const same = (a, b) =>
157
+ a === b ||
158
+ (!!a && !!b && a.id === b.id && a.row === b.row &&
159
+ Math.abs(a.top - b.top) < 0.5 && Math.abs(a.left - b.left) < 0.5 &&
160
+ Math.abs(a.width - b.width) < 0.5 && Math.abs(a.height - b.height) < 0.5);
161
+ const sameList = (a, b) => a.length === b.length && a.every((x, i) => same(x, b[i]));
162
+
163
+ /** Every other live copy of `key`, as boxes (the primary one excluded). */
164
+ function twinsOf(key, primary) {
165
+ if (!key) return [];
166
+ const out = [];
167
+ findPreviewElements(key).forEach((el, i) => {
168
+ if (el === primary) return;
169
+ const box = rectOf(el, key, `${key}#${i}`);
170
+ if (box) out.push(box);
171
+ });
172
+ return out;
173
+ }
174
+
175
+ function measure() {
176
+ const inset = Math.round(topInset());
177
+ if (inset !== view.safeTop) view.safeTop = inset;
178
+
179
+ // The exact copies: the one clicked, the one under the pointer. Selected /
180
+ // hovered from the dashboard's layer tree there's no copy — the first one.
181
+ const selectedEl = resolvePreviewElement(selectedKey.value, previewElements.selected);
182
+ const selected = rectOf(selectedEl, selectedKey.value);
183
+ if (!same(view.selected, selected)) view.selected = selected;
184
+
185
+ const hoverEl = resolvePreviewElement(hoveredKey.value, previewElements.hover);
186
+ const hoverKey = hoveredKey.value && hoveredKey.value !== selectedKey.value ? hoveredKey.value : null;
187
+ const hover = hoverKey ? rectOf(hoverEl, hoverKey) : null;
188
+ if (!same(view.hover, hover)) view.hover = hover;
189
+
190
+ // Parents of the copy under the pointer — its own card, not card #1's.
191
+ const parents = hoveredKey.value
192
+ ? chain(hoveredKey.value)
193
+ .slice(0, -1)
194
+ .map((key) => rectOf(hoverEl?.closest(previewSelector(key)) ?? resolvePreviewElement(key), key))
195
+ .filter(Boolean)
196
+ : [];
197
+ if (!sameList(parents, view.parents)) view.parents = parents;
198
+
199
+ const twins = [
200
+ ...twinsOf(selectedKey.value, selectedEl),
201
+ ...(hoverKey ? twinsOf(hoverKey, hoverEl) : []),
202
+ ];
203
+ if (!sameList(twins, view.twins)) view.twins = twins;
204
+ }
205
+
206
+ let raf = 0;
207
+ function loop() {
208
+ measure();
209
+ raf = requestAnimationFrame(loop);
210
+ }
211
+ onMounted(() => {
212
+ raf = requestAnimationFrame(loop);
213
+ window.addEventListener("message", onFrameMessage);
214
+ // The dashboard also pushes FRAME on READY, but this component may mount
215
+ // after that — ask again so the radius is never missed.
216
+ if (window.parent !== window) {
217
+ window.parent.postMessage({ protocol: PROTOCOL, type: "FRAME_REQUEST" }, "*");
218
+ }
219
+ });
220
+ onBeforeUnmount(() => {
221
+ cancelAnimationFrame(raf);
222
+ window.removeEventListener("message", onFrameMessage);
223
+ });
224
+
225
+ /* ---------------------------------------------------------- frame corners */
226
+
227
+ /**
228
+ * The dashboard rounds and clips the preview (a rounded shell around this
229
+ * iframe). It can't be read from in here — cross-origin — so PreviewFrame
230
+ * sends the radius of the visible area: { radius } in px, 0 when the shell
231
+ * is square (phone-width dashboard).
232
+ */
233
+ const PROTOCOL = "osimart-builder/1";
234
+ const frameRadius = ref(0);
235
+
236
+ function onFrameMessage(e) {
237
+ if (e.source !== window.parent) return;
238
+ const d = e.data;
239
+ if (!d || d.protocol !== PROTOCOL || d.type !== "FRAME") return;
240
+ const r = Number(d.payload?.radius);
241
+ frameRadius.value = Number.isFinite(r) && r > 0 ? r : 0;
242
+ }
243
+
244
+ /**
245
+ * boxStyle: the box's position plus rounded corners near the frame's corners.
246
+ * A box corner within `frameRadius` of both frame edges it's heading for
247
+ * (e.g. top + left) gets a radius that grows from 0 to the full frame radius
248
+ * as it closes in, so the outline eases into the curve as it nears the top
249
+ * and matches it exactly once touching. A corner that's scrolled past the
250
+ * edge is off screen, so it stays square.
251
+ */
252
+ const EDGE = 1;
253
+
254
+ function cornerRadius(dx, dy) {
255
+ const R = frameRadius.value;
256
+ if (R <= 0 || dx < -EDGE || dy < -EDGE) return 0;
257
+ return Math.max(0, R - Math.max(dx, dy, 0));
258
+ }
259
+
260
+ function boxStyle(box) {
261
+ const w = window.innerWidth;
262
+ const h = window.innerHeight;
263
+ const right = w - (box.left + box.width);
264
+ const bottom = h - (box.top + box.height);
265
+ const tl = cornerRadius(box.left, box.top);
266
+ const tr = cornerRadius(right, box.top);
267
+ const br = cornerRadius(right, bottom);
268
+ const bl = cornerRadius(box.left, bottom);
269
+ return {
270
+ top: `${box.top}px`,
271
+ left: `${box.left}px`,
272
+ width: `${box.width}px`,
273
+ height: `${box.height}px`,
274
+ borderRadius: `${tl}px ${tr}px ${br}px ${bl}px`,
275
+ };
276
+ }
277
+
278
+ /* ------------------------------------------------------------------ tags */
279
+
280
+ const TAG_H = 22;
281
+
282
+ /**
283
+ * Where a box's name tag goes, like Shopify's:
284
+ * room above the box (below any pinned header) -> sitting on the box's top
285
+ * edge like a tab (overlapping the border, so no gap shows): only its top
286
+ * corners are rounded
287
+ * box top visible but no room above -> at the box's top-left
288
+ * corner
289
+ * box top scrolled under the header / off screen -> stuck just below the
290
+ * header, riding down with the box until the box's bottom pushes it up
291
+ * — in both of these it hangs from the edge above it, so only its
292
+ * bottom corners are rounded
293
+ * `border` is the box's outline width (1 hover, 2 selected). The tag is
294
+ * nudged 2px left of the box.
295
+ */
296
+ const TAG_R = 4;
297
+ function tagStyle(box, border = 1) {
298
+ const safe = view.safeTop;
299
+ let top;
300
+ let above = false;
301
+ if (box.top - TAG_H >= safe) { top = box.top - TAG_H + border; above = true; }
302
+ else if (box.top >= safe) top = box.top;
303
+ else top = Math.min(safe, box.top + box.height - TAG_H - 2);
304
+ return {
305
+ top: `${top}px`,
306
+ left: `${Math.max(0, Math.min(box.left, window.innerWidth - 60) - 2)}px`,
307
+ borderRadius: above ? `${TAG_R}px ${TAG_R}px 0 0` : `0 0 ${TAG_R}px ${TAG_R}px`,
308
+ };
309
+ }
310
+
311
+ const hoverCrumbs = computed(() => (view.hover ? chain(view.hover.key) : []));
312
+ const selectedCrumbs = computed(() => (view.selected ? chain(view.selected.key) : []));
313
+ const hoveringSelected = computed(() => !!selectedKey.value && hoveredKey.value === selectedKey.value);
314
+ function selectKey(key) {
315
+ const info = index.value[key];
316
+ if (!info) return;
317
+ send("SECTION_CLICK", { key: info.section, blockKey: info.kind === "BLOCK" ? key : null });
318
+ }
319
+
320
+ function act(action) {
321
+ const key = view.selected?.key;
322
+ const info = index.value[key];
323
+ if (!info) return;
324
+ send("ACTION", { kind: info.kind, key, action });
325
+ }
326
+
327
+ /* ------------------------------------------------------------- "+" buttons */
328
+
329
+ /** The box the "+" buttons belong to: the hovered one, else the selected one. */
330
+ const active = computed(() => view.hover ?? view.selected);
331
+
332
+ const plusButtons = computed(() => {
333
+ const box = active.value;
334
+ if (!box) return [];
335
+ const info = index.value[box.key];
336
+ if (!info) return [];
337
+
338
+ if (info.kind === "SECTION") {
339
+ const cx = box.left + box.width / 2;
340
+ return [
341
+ { id: "s-before", label: "Add section", wide: true, x: cx, y: box.top, add: () => send("ADD_SECTION", { index: info.position, area: info.area }) },
342
+ { id: "s-after", label: "Add section", wide: true, x: cx, y: box.top + box.height, add: () => send("ADD_SECTION", { index: info.position + 1, area: info.area }) },
343
+ ];
344
+ }
345
+
346
+ if (isFixed(box.key)) return [];
347
+ const at = siblings(info.section, info.parent).indexOf(box.key);
348
+ const addAt = (i, x, y) => send("ADD_BLOCK", { section: info.section, parent: info.parent, index: i, x, y });
349
+ const buttons = box.row
350
+ ? [
351
+ { id: "b-before", x: box.left, y: box.top + box.height / 2, add: (e) => addAt(at, e.clientX, e.clientY) },
352
+ { id: "b-after", x: box.left + box.width, y: box.top + box.height / 2, add: (e) => addAt(at + 1, e.clientX, e.clientY) },
353
+ ]
354
+ : [
355
+ { id: "b-before", x: box.left + box.width / 2, y: box.top, add: (e) => addAt(at, e.clientX, e.clientY) },
356
+ { id: "b-after", x: box.left + box.width / 2, y: box.top + box.height, add: (e) => addAt(at + 1, e.clientX, e.clientY) },
357
+ ];
358
+
359
+ // An empty group: "Add block" in the middle, into it.
360
+ if (isContainer(box.key) && !siblings(info.section, box.key).length) {
361
+ buttons.push({
362
+ id: "b-inside", label: "Add block", wide: true,
363
+ x: box.left + box.width / 2, y: box.top + box.height / 2,
364
+ add: (e) => send("ADD_BLOCK", { section: info.section, parent: box.key, index: 0, x: e.clientX, y: e.clientY }),
365
+ });
366
+ }
367
+ return buttons;
368
+ });
369
+ </script>
370
+
371
+ <template>
372
+ <!-- On <body>, not inside the page: the layout's wrappers can form their own
373
+ stacking context, and then no z-index lifts the overlay above the
374
+ site's sticky header. -->
375
+ <Teleport to="body">
376
+ <!-- Off from the dashboard's top bar: the page shows exactly as customers
377
+ see it. Clicking a block still selects it. -->
378
+ <div v-if="isPreview && overlayOn" class="osi-ov" data-osi-overlay>
379
+ <!-- the hovered block's parents -->
380
+ <div v-for="p in view.parents" :key="`p-${p.key}`" class="osi-ov-box is-parent" :style="boxStyle(p)" />
381
+
382
+ <!-- the other copies of the hovered / selected block -->
383
+ <div v-for="tw in view.twins" :key="`t-${tw.id}`" class="osi-ov-box is-twin" :style="boxStyle(tw)" />
384
+
385
+ <!-- hover -->
386
+ <template v-if="view.hover">
387
+ <div class="osi-ov-box is-hover" :style="boxStyle(view.hover)" />
388
+ <div class="osi-ov-tag is-hover" :style="tagStyle(view.hover, 1)">
389
+ <template v-for="(key, i) in hoverCrumbs" :key="`h-${key}`">
390
+ <span v-if="i" class="osi-ov-sep">›</span>
391
+ <span :class="{ 'is-self': i === hoverCrumbs.length - 1 }">{{ nameOf(key) }}</span>
392
+ </template>
393
+ <!-- <span v-if="hintOf(view.hover.key)" class="osi-ov-hint">{{ hintOf(view.hover.key) }}</span> -->
394
+ </div>
395
+ </template>
396
+
397
+ <!-- selected -->
398
+ <template v-if="view.selected">
399
+ <div class="osi-ov-box is-selected" :style="boxStyle(view.selected)" />
400
+ <div v-if="hoveringSelected" class="osi-ov-tag is-selected" :style="tagStyle(view.selected, 2)">
401
+ <template v-for="(key, i) in selectedCrumbs" :key="`s-${key}`">
402
+ <span v-if="i" class="osi-ov-sep">›</span>
403
+ <button
404
+ v-if="i < selectedCrumbs.length - 1"
405
+ type="button"
406
+ class="osi-ov-crumb"
407
+ :title="`Select ${nameOf(key)}`"
408
+ @click.stop="selectKey(key)"
409
+ >{{ nameOf(key) }}</button>
410
+ <span v-else class="is-self">{{ nameOf(key) }}</span>
411
+ </template>
412
+ <span v-if="!isFixed(view.selected.key)" class="osi-ov-tools">
413
+ <button type="button" title="Move up" @click.stop="act('up')">
414
+ <svg viewBox="0 0 24 24"><path d="M12 19V5M5 12l7-7 7 7" /></svg>
415
+ </button>
416
+ <button type="button" title="Move down" @click.stop="act('down')">
417
+ <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12l7 7 7-7" /></svg>
418
+ </button>
419
+ <button type="button" title="Duplicate" @click.stop="act('duplicate')">
420
+ <svg viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" /></svg>
421
+ </button>
422
+ <button
423
+ v-if="!isRequired(view.selected.key)"
424
+ type="button"
425
+ title="Delete"
426
+ class="is-danger"
427
+ @click.stop="act('delete')"
428
+ >
429
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-[15px] shrink-0"><path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"></path></svg>
430
+ </button>
431
+ </span>
432
+ </div>
433
+ </template>
434
+
435
+ <!-- "+" -->
436
+ <button
437
+ v-for="b in plusButtons"
438
+ :key="b.id"
439
+ type="button"
440
+ class="osi-ov-plus"
441
+ :class="{ 'is-wide': b.wide }"
442
+ :style="{ top: `${b.y}px`, left: `${b.x}px` }"
443
+ :title="b.label || 'Add block'"
444
+ @click.stop="b.add($event)"
445
+ >
446
+ <svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14" /></svg>
447
+ <span v-if="b.label">{{ b.label }}</span>
448
+ </button>
449
+ </div>
450
+ </Teleport>
451
+ </template>
452
+
453
+ <style scoped>
454
+ .osi-ov {
455
+ --ov-blue: #2c6ecb;
456
+ position: fixed;
457
+ inset: 0;
458
+ z-index: 2147483000;
459
+ pointer-events: none;
460
+ font: 500 12px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
461
+ }
462
+ .osi-ov-box {
463
+ position: fixed;
464
+ box-sizing: border-box;
465
+ pointer-events: none;
466
+ }
467
+ .osi-ov-box.is-parent {
468
+ border: 1px dashed rgba(44, 110, 203, 0.45);
469
+ }
470
+ .osi-ov-box.is-twin {
471
+ border: 1px solid rgba(44, 110, 203, 0.55);
472
+ background: rgba(44, 110, 203, 0.04);
473
+ }
474
+ .osi-ov-box.is-hover {
475
+ border: 1px solid var(--ov-blue);
476
+ }
477
+ .osi-ov-box.is-selected {
478
+ border: 2px solid var(--ov-blue);
479
+ }
480
+ .osi-ov-tag {
481
+ font-family: 'Poppins', sans-serif;
482
+ position: fixed;
483
+ height: 22px;
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 4px;
487
+ max-width: calc(100vw - 8px);
488
+ padding: 0 8px;
489
+ border-radius: 4px;
490
+ background: var(--ov-blue);
491
+ color: #fff;
492
+ white-space: nowrap;
493
+ overflow: hidden;
494
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
495
+ }
496
+ .osi-ov-tag.is-hover {
497
+ pointer-events: none;
498
+ opacity: 0.95;
499
+ }
500
+ .osi-ov-tag.is-selected {
501
+ pointer-events: auto;
502
+ }
503
+ .osi-ov-sep {
504
+ opacity: 0.6;
505
+ }
506
+ .osi-ov-tag .is-self {
507
+ font-weight: 600;
508
+ }
509
+ .osi-ov-hint {
510
+ margin-inline-start: 4px;
511
+ opacity: 0.75;
512
+ font-weight: 400;
513
+ overflow: hidden;
514
+ text-overflow: ellipsis;
515
+ max-width: 160px;
516
+ }
517
+ .osi-ov-crumb {
518
+ all: unset;
519
+ cursor: pointer;
520
+ opacity: 0.85;
521
+ }
522
+ .osi-ov-crumb:hover,
523
+ .osi-ov-crumb:focus-visible {
524
+ opacity: 1;
525
+ text-decoration: underline;
526
+ }
527
+ .osi-ov-tools {
528
+ display: inline-flex;
529
+ gap: 2px;
530
+ margin-inline-start: 6px;
531
+ padding-inline-start: 6px;
532
+ border-inline-start: 1px solid rgba(255, 255, 255, 0.35);
533
+ }
534
+ .osi-ov-tools button {
535
+ all: unset;
536
+ display: grid;
537
+ place-items: center;
538
+ width: 18px;
539
+ height: 18px;
540
+ border-radius: 3px;
541
+ cursor: pointer;
542
+ }
543
+ .osi-ov-tools button:hover,
544
+ .osi-ov-tools button:focus-visible {
545
+ background: rgba(255, 255, 255, 0.2);
546
+ }
547
+ .osi-ov-tools button.is-danger:hover {
548
+ background: #d72c0d;
549
+ }
550
+ .osi-ov svg {
551
+ width: 12px;
552
+ height: 12px;
553
+ fill: none;
554
+ stroke: currentColor;
555
+ stroke-width: 2.2;
556
+ stroke-linecap: round;
557
+ stroke-linejoin: round;
558
+ }
559
+ .osi-ov-plus {
560
+ font-family: 'Poppins', sans-serif !important;
561
+ all: unset;
562
+ position: fixed;
563
+ transform: translate(-50%, -50%);
564
+ display: inline-flex;
565
+ align-items: center;
566
+ justify-content: center;
567
+ gap: 4px;
568
+ width: 22px;
569
+ height: 22px;
570
+ border-radius: 999px;
571
+ background: var(--ov-blue);
572
+ color: #fff;
573
+ cursor: pointer;
574
+ pointer-events: auto;
575
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
576
+ transition: transform 0.12s ease;
577
+ }
578
+ .osi-ov-plus.is-wide {
579
+ width: auto;
580
+ padding: 0 10px 0 8px;
581
+ }
582
+ </style>