@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,29 @@
1
+ import HomeServices from "#osi/services/HomeServices";
2
+
3
+ export function useAnnouncements() {
4
+ const announcements = useState("announcements-data", () => null);
5
+ const announcementsLoaded = useState("announcements-loaded", () => false);
6
+ // Read now, in setup scope, while the Nuxt instance is still available —
7
+ // not inside loadAnnouncements(), which runs after app.vue's own await and
8
+ // would otherwise lose the ability to call useState() on the server.
9
+ const baseUrl = useState("base-url", () => "");
10
+
11
+ const loadAnnouncements = async () => {
12
+ if (announcementsLoaded.value) return announcements.value;
13
+
14
+ try {
15
+ const data = await HomeServices.getAnnouncements(baseUrl.value);
16
+ announcements.value = data;
17
+ announcementsLoaded.value = true;
18
+ } catch (error) {
19
+ console.error("[useAnnouncements] Error loading announcements:", error);
20
+ }
21
+ return announcements.value;
22
+ };
23
+
24
+ return {
25
+ announcements,
26
+ announcementsLoaded,
27
+ loadAnnouncements,
28
+ };
29
+ }
@@ -0,0 +1,104 @@
1
+ import { useToast } from "vue-toastification";
2
+
3
+ /**
4
+ * Turn an apiService error into a toast.
5
+ *
6
+ * The same flattening block was copy-pasted into checkout (x3), the homepage,
7
+ * the product page, profile-details and the cart sidebar — each copy slightly
8
+ * different, and one of them (checkout's submitForm) referenced `messages`
9
+ * before declaring it, so every checkout failure showed the generic fallback.
10
+ *
11
+ * apiService attaches the parsed body as `error.data`, which DRF returns in
12
+ * several shapes:
13
+ * ["Not enough stock"] list serializer / non_field
14
+ * { email: ["Already taken"], name: [...] } field errors
15
+ * { detail: "Not found" } APIException
16
+ * { address: { country: ["Required"] } } nested serializer
17
+ * AuthenticationService.register rejects with the bare body, so the error
18
+ * itself can be the field-error object with no `.data` at all.
19
+ */
20
+ export function useApiErrorToast() {
21
+ /** Depth-first flatten to a flat array of strings. */
22
+ function collect(value, out = [], label = null) {
23
+ if (value === null || value === undefined) return out;
24
+
25
+ if (typeof value === "string") {
26
+ if (value.trim()) out.push(label ? `${label}: ${value.trim()}` : value.trim());
27
+ return out;
28
+ }
29
+
30
+ if (Array.isArray(value)) {
31
+ value.forEach((entry) => collect(entry, out, label));
32
+ return out;
33
+ }
34
+
35
+ if (typeof value === "object") {
36
+ Object.entries(value).forEach(([key, entry]) => collect(entry, out, key));
37
+ return out;
38
+ }
39
+
40
+ out.push(label ? `${label}: ${String(value)}` : String(value));
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Every human-readable message in an error, deduped and in order.
46
+ * Returns [] when there's nothing usable — callers fall back themselves.
47
+ */
48
+ function messagesFrom(error) {
49
+ if (!error) return [];
50
+
51
+ // `detail` is the whole message when present; the other keys are noise.
52
+ const body = error.data ?? error;
53
+ if (body && typeof body === "object" && typeof body.detail === "string") {
54
+ return [body.detail];
55
+ }
56
+
57
+ let messages = [];
58
+ if (body && typeof body === "object") messages = collect(body);
59
+
60
+ // A bare Error (network failure, thrown guard) has no body worth reading.
61
+ if (!messages.length && typeof error.message === "string" && error.message) {
62
+ messages = [error.message];
63
+ }
64
+
65
+ return [...new Set(messages)];
66
+ }
67
+
68
+ /** One message plain; several as a bulleted list, matching the old behaviour. */
69
+ function formatMessages(messages) {
70
+ if (!messages.length) return "";
71
+ return messages.length === 1
72
+ ? messages[0]
73
+ : messages.map((message) => `\u2022 ${message}`).join("\n");
74
+ }
75
+
76
+ /**
77
+ * @param {unknown} error
78
+ * @param {string} fallback shown when the error carries nothing readable
79
+ */
80
+ function showApiError(error, fallback = "Something went wrong. Please try again.") {
81
+ const text = formatMessages(messagesFrom(error)) || fallback;
82
+
83
+ // useToast() needs an active Vue instance. Every old copy of this block
84
+ // wrapped it defensively because services throw from places that don't have
85
+ // one — keep that, and never let reporting an error throw a second error.
86
+ try {
87
+ useToast().error(text);
88
+ } catch {
89
+ console.error("[api]", text, error);
90
+ }
91
+
92
+ return text;
93
+ }
94
+
95
+ function showApiSuccess(message) {
96
+ try {
97
+ useToast().success(message);
98
+ } catch {
99
+ /* no active instance — nothing to show */
100
+ }
101
+ }
102
+
103
+ return { showApiError, showApiSuccess, messagesFrom, formatMessages };
104
+ }
@@ -0,0 +1,99 @@
1
+ import AuthenticationService from "#osi/services/Authentication/AuthenticationService";
2
+ import CartServices from "#osi/services/CartServices";
3
+ import { authBus } from "#osi/services/AuthBus";
4
+ import { eventBus } from "#osi/utils/eventBus";
5
+ import { getDeviceInfo } from "#osi/utils/device";
6
+ import { analytics } from "#osi/utils/analytics";
7
+ import { useToast } from "vue-toastification";
8
+
9
+ export function useAuth() {
10
+ const router = useRouter();
11
+ const route = useRoute();
12
+ const isLoginLoading = useState("auth-login-loading", () => false);
13
+ const isRegisterLoading = useState("auth-register-loading", () => false);
14
+
15
+ const isLoggedIn = computed(() => authBus.isLoggedIn);
16
+ const userInfo = computed(() => authBus.userInfo);
17
+
18
+ function isValidEmail(email) {
19
+ const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
20
+ return regex.test(email);
21
+ }
22
+
23
+ async function login(formData) {
24
+ const toast = useToast();
25
+ isLoginLoading.value = true;
26
+ try {
27
+ const deviceInfo = getDeviceInfo();
28
+ const response = await AuthenticationService.login({
29
+ ...formData,
30
+ ...deviceInfo,
31
+ });
32
+
33
+ toast.success("You have successfully signed into your account!");
34
+ await authBus.checkAuth();
35
+ analytics.login("email");
36
+ const person = authBus.userInfo;
37
+ if (person) {
38
+ analytics.identify({
39
+ email: person.email,
40
+ phone: person.mobile_number,
41
+ firstName: person.first_name,
42
+ lastName: person.last_name,
43
+ });
44
+ }
45
+ await CartServices.mergeCart();
46
+
47
+ eventBus.emit("cartUpdated");
48
+ const redirectPath = route.query.redirect || "/";
49
+ router.push(redirectPath);
50
+ return response;
51
+ } catch (error) {
52
+ toast.error("Wrong email or password");
53
+ throw error;
54
+ } finally {
55
+ isLoginLoading.value = false;
56
+ }
57
+ }
58
+
59
+ async function register(registerData) {
60
+ const toast = useToast();
61
+ isRegisterLoading.value = true;
62
+ try {
63
+ const response = await AuthenticationService.register(registerData);
64
+ router.push({
65
+ path: "/verificationcode",
66
+ query: { email: registerData.email },
67
+ });
68
+ return response;
69
+ } catch (error) {
70
+ if (error && typeof error === 'object') {
71
+ const errorMessages = Object.values(error).flat();
72
+ if (errorMessages.length > 1) {
73
+ toast.error(`• ${errorMessages.join("\n• ")}`);
74
+ } else if (errorMessages.length === 1) {
75
+ toast.error(errorMessages[0]);
76
+ }
77
+ }
78
+ throw error;
79
+ } finally {
80
+ isRegisterLoading.value = false;
81
+ }
82
+ }
83
+
84
+ async function logout() {
85
+ await authBus.logout();
86
+ router.push("/");
87
+ }
88
+
89
+ return {
90
+ isLoggedIn,
91
+ userInfo,
92
+ isLoginLoading,
93
+ isRegisterLoading,
94
+ isValidEmail,
95
+ login,
96
+ register,
97
+ logout,
98
+ };
99
+ }
@@ -0,0 +1,25 @@
1
+ import HomeServices from "#osi/services/HomeServices";
2
+
3
+ export function useBanners() {
4
+ // Resolved here, synchronously in setup, before useAsyncData's fetcher
5
+ // (which runs later, past the point where useState() is safe on the server).
6
+ const baseUrl = useState("base-url", () => "");
7
+
8
+ const { data, pending, error, refresh } = useAsyncData(
9
+ "banners",
10
+ async () => {
11
+ const response = await HomeServices.getBanners(baseUrl.value);
12
+ return Array.isArray(response) ? response : (response?.results || []);
13
+ },
14
+ {
15
+ default: () => [],
16
+ }
17
+ );
18
+
19
+ return {
20
+ banners: data,
21
+ isLoading: pending,
22
+ error,
23
+ fetchBanners: refresh,
24
+ };
25
+ }
@@ -0,0 +1,320 @@
1
+ const PROTOCOL = "osimart-builder/1";
2
+
3
+ /**
4
+ * Raw preview overlay state.
5
+ *
6
+ * Split out from useBuilderPreview() so useStorefront() can read the overlay
7
+ * without pulling in the message handling — that would be a cycle, since
8
+ * onMessage calls useStorefront().setLocale().
9
+ *
10
+ * useState, never module refs: one Nuxt process serves every tenant.
11
+ */
12
+ export function usePreviewOverlay() {
13
+ return {
14
+ active: useState("preview-active", () => false),
15
+ state: useState("preview-state", () => null),
16
+ theme: useState("preview-theme", () => null),
17
+ menus: useState("preview-menus", () => null),
18
+ device: useState("preview-device", () => "DESKTOP"),
19
+ selectedKey: useState("preview-selected", () => null),
20
+ hoveredKey: useState("preview-hovered", () => null),
21
+ // key -> { label, hint, container } for the editor overlay's name tags,
22
+ // sent by the dashboard with every STATE (the preview has no schemas).
23
+ labels: useState("preview-labels", () => ({})),
24
+ // The editor overlay on / off — the dashboard's top-bar toggle.
25
+ overlayOn: useState("preview-overlay-on", () => true),
26
+ };
27
+ }
28
+
29
+ /** CSS selector for a section / block key. */
30
+ export function previewSelector(key) {
31
+ return `[data-osi-block="${CSS.escape(key)}"], [data-osi-section="${CSS.escape(key)}"]`;
32
+ }
33
+
34
+ /** The key an element stands for (block or section), or null. */
35
+ export function previewKeyOf(el) {
36
+ return el?.dataset?.osiBlock ?? el?.dataset?.osiSection ?? null;
37
+ }
38
+
39
+ /**
40
+ * Every live (not cloned) element for a key. One block key can be drawn many
41
+ * times — a card template's parts appear once per item in a list — and
42
+ * carousels / the marquee render `inert` copies, which never count.
43
+ */
44
+ export function findPreviewElements(key, max = 60) {
45
+ if (!import.meta.client || !key) return [];
46
+ const out = [];
47
+ for (const el of document.querySelectorAll(previewSelector(key))) {
48
+ if (el.closest("[inert]")) continue;
49
+ out.push(el);
50
+ if (out.length >= max) break;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /** The first live element for a key. */
56
+ export function findPreviewElement(key) {
57
+ return findPreviewElements(key, 1)[0] ?? null;
58
+ }
59
+
60
+ /**
61
+ * The exact elements the pointer is on / last clicked.
62
+ *
63
+ * A key alone can't say WHICH copy of a repeated block (the title of the
64
+ * second card, not the first) — the overlay needs the element. Module-level
65
+ * and non-reactive on purpose: DOM nodes only exist on the client (never in
66
+ * SSR state, so nothing leaks between tenants), and the overlay polls them
67
+ * every frame anyway.
68
+ */
69
+ export const previewElements = { hover: null, selected: null };
70
+
71
+ /**
72
+ * `hint` when it's still on the page and still stands for `key`; else the
73
+ * first live element for `key`.
74
+ */
75
+ export function resolvePreviewElement(key, hint) {
76
+ if (!key) return null;
77
+ if (hint?.isConnected && previewKeyOf(hint) === key && !hint.closest("[inert]")) return hint;
78
+ return findPreviewElement(key);
79
+ }
80
+
81
+ /** Accept either { settings: {...} } or an already-flat settings map. */
82
+ function settingsOf(payload) {
83
+ if (!payload) return null;
84
+ return payload.settings ?? payload;
85
+ }
86
+
87
+ /**
88
+ * The dashboard's live preview bridge.
89
+ *
90
+ * The builder iframes this storefront and drives it over postMessage. On the
91
+ * initial load the page fetches from the API so a hard refresh works; after the
92
+ * READY handshake every edit arrives over the wire and the page re-renders from
93
+ * memory. Nothing unsaved ever touches the database.
94
+ *
95
+ * Envelope: { protocol: "osimart-builder/1", type, payload }
96
+ *
97
+ * Storefront -> dashboard: READY, SECTION_CLICK, HOVER,
98
+ * ADD_BLOCK, ADD_SECTION, ACTION (editor overlay)
99
+ * Dashboard -> storefront: STATE (+ labels), THEME, SETTING, MENUS, DEVICE,
100
+ * LOCALE, SELECT, HOVER, OVERLAY { on }
101
+ *
102
+ * Hover comes from the pointer here (deepest block under it) and from the
103
+ * dashboard's layer tree; both land in hoveredKey, which BuilderOverlay draws.
104
+ *
105
+ * Three messages carry theme data rather than one, because they run at very
106
+ * different rates:
107
+ *
108
+ * STATE whole editor snapshot — section add/remove/reorder
109
+ * THEME the full theme settings map — any settings-panel commit
110
+ * SETTING one { key, value } — colour pickers and sliders, which fire on
111
+ * every mouse move and must not re-serialize the whole theme
112
+ *
113
+ * MENUS is separate from STATE for a different reason: menus are not part of
114
+ * the theme. A merchant can create one in another tab, and the preview has to
115
+ * be able to render a handle that was never in the bootstrap payload.
116
+ */
117
+ export function useBuilderPreview() {
118
+ const {
119
+ active: isPreview,
120
+ state,
121
+ theme: previewThemeState,
122
+ menus: previewMenusState,
123
+ device,
124
+ selectedKey,
125
+ hoveredKey,
126
+ labels,
127
+ overlayOn,
128
+ } = usePreviewOverlay();
129
+
130
+ function send(type, payload = null) {
131
+ if (!import.meta.client) return;
132
+ if (window.parent === window) return;
133
+ window.parent.postMessage({ protocol: PROTOCOL, type, payload }, "*");
134
+ }
135
+
136
+ function onMessage(e) {
137
+ const d = e.data;
138
+ if (!d || d.protocol !== PROTOCOL) return;
139
+
140
+ switch (d.type) {
141
+ case "STATE": {
142
+ const payload = d.payload ?? {};
143
+ state.value = payload;
144
+
145
+ // Only overwrite the overlays this STATE actually carries. A STATE
146
+ // sent for a section reorder must not wipe a SETTING pushed a moment
147
+ // earlier — that reads as the colour snapping back mid-drag.
148
+ if (payload.theme !== undefined) {
149
+ previewThemeState.value = settingsOf(payload.theme);
150
+ }
151
+ if (payload.menus !== undefined) {
152
+ previewMenusState.value = payload.menus;
153
+ }
154
+ if (payload.labels !== undefined) {
155
+ labels.value = payload.labels ?? {};
156
+ }
157
+ break;
158
+ }
159
+
160
+ case "THEME":
161
+ previewThemeState.value = settingsOf(d.payload);
162
+ break;
163
+
164
+ case "SETTING": {
165
+ const { key, value } = d.payload ?? {};
166
+ if (!key) break;
167
+ previewThemeState.value = { ...(previewThemeState.value ?? {}), [key]: value };
168
+ break;
169
+ }
170
+
171
+ case "MENUS":
172
+ previewMenusState.value = d.payload ?? [];
173
+ break;
174
+
175
+ case "DEVICE":
176
+ device.value = d.payload;
177
+ break;
178
+
179
+ case "LOCALE":
180
+ useStorefront().setLocale(d.payload);
181
+ break;
182
+
183
+ case "SELECT": {
184
+ selectedKey.value = d.payload?.key ?? null;
185
+ // Selected from the dashboard's layer tree: no particular copy — the
186
+ // overlay falls back to the first. From a click here: the clicked one.
187
+ if (previewKeyOf(previewElements.selected) !== selectedKey.value) previewElements.selected = null;
188
+ // Bring it into view only if it isn't already — a click in the
189
+ // preview selects what's on screen and must not make the page jump.
190
+ const el = resolvePreviewElement(selectedKey.value, previewElements.selected);
191
+ if (el) {
192
+ const r = el.getBoundingClientRect();
193
+ const visible = r.bottom > 0 && r.top < window.innerHeight;
194
+ if (!visible) el.scrollIntoView({ behavior: "smooth", block: "center" });
195
+ }
196
+ break;
197
+ }
198
+
199
+ case "HOVER":
200
+ // From the layer tree: a key, not a copy.
201
+ previewElements.hover = null;
202
+ hoveredKey.value = d.payload?.key ?? null;
203
+ break;
204
+
205
+ case "OVERLAY":
206
+ overlayOn.value = d.payload?.on !== false;
207
+ break;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Hover from the pointer: the deepest live block (or section) under it.
213
+ * Pointer over the overlay's own controls keeps the current hover, or the
214
+ * "+" buttons would vanish the moment you reach for them.
215
+ */
216
+ let lastHover = null;
217
+ function onPointerMove(e) {
218
+ if (e.target.closest?.("[data-osi-overlay]")) return;
219
+ let el = e.target.closest?.("[data-osi-block], [data-osi-section]") ?? null;
220
+ while (el && el.closest("[inert]")) {
221
+ el = el.parentElement?.closest("[data-osi-block], [data-osi-section]") ?? null;
222
+ }
223
+ // Always the element under the pointer, even when the key is unchanged:
224
+ // moving from card 1's title to card 2's title is the same key, another
225
+ // box.
226
+ previewElements.hover = el;
227
+ const next = el
228
+ ? el.dataset.osiBlock
229
+ ? { type: "BLOCK", key: el.dataset.osiBlock }
230
+ : { type: "SECTION", key: el.dataset.osiSection }
231
+ : null;
232
+ if (next?.key === lastHover?.key) return;
233
+ lastHover = next;
234
+ hoveredKey.value = next?.key ?? null;
235
+ send("HOVER", next);
236
+ }
237
+ function onPointerLeave() {
238
+ previewElements.hover = null;
239
+ if (!lastHover) return;
240
+ lastHover = null;
241
+ hoveredKey.value = null;
242
+ send("HOVER", null);
243
+ }
244
+
245
+ function onClick(e) {
246
+ // The overlay's buttons do their own thing.
247
+ if (e.target.closest?.("[data-osi-overlay]")) return;
248
+ const section = e.target.closest("[data-osi-section]");
249
+ if (!section) return;
250
+ const block = e.target.closest("[data-osi-block]");
251
+ // Remember which copy was clicked, so the selection box lands on it (a
252
+ // carousel's inert clone can't carry a box: the first live copy then).
253
+ const picked = block ?? section;
254
+ previewElements.selected = picked.closest("[inert]") ? null : picked;
255
+ // Inside the editor a click means "select this", not "navigate away".
256
+ e.preventDefault();
257
+ send("SECTION_CLICK", {
258
+ key: section.dataset.osiSection,
259
+ blockKey: block?.dataset.osiBlock ?? null,
260
+ });
261
+ }
262
+
263
+ function start() {
264
+ if (!import.meta.client) return;
265
+ try {
266
+ if (window.parent === window && window.self === window.top) {
267
+ const route = useRoute();
268
+ if (
269
+ !route?.path?.includes("__builder-preview") &&
270
+ route?.query?.osi_preview === undefined &&
271
+ route?.query?.preview === undefined
272
+ ) {
273
+ return;
274
+ }
275
+ }
276
+ } catch {
277
+ // In restricted iframe
278
+ }
279
+
280
+ isPreview.value = true;
281
+ window.addEventListener("message", onMessage);
282
+ document.addEventListener("click", onClick, true);
283
+ document.addEventListener("pointermove", onPointerMove, { passive: true });
284
+ document.documentElement.addEventListener("pointerleave", onPointerLeave);
285
+
286
+ // READY goes LAST. The dashboard answers it immediately with STATE, THEME,
287
+ // MENUS, DEVICE and LOCALE — a listener attached after this misses all of
288
+ // them, and the preview sits there looking broken with nothing in the
289
+ // console.
290
+ send("READY");
291
+ }
292
+
293
+ function stop() {
294
+ if (!import.meta.client) return;
295
+ window.removeEventListener("message", onMessage);
296
+ document.removeEventListener("click", onClick, true);
297
+ document.removeEventListener("pointermove", onPointerMove);
298
+ document.documentElement.removeEventListener("pointerleave", onPointerLeave);
299
+ isPreview.value = false;
300
+ }
301
+
302
+ /**
303
+ * Sections to render: live editor state once connected, the fetched payload
304
+ * before the handshake or on a hard refresh.
305
+ */
306
+ function sectionsFrom(fallback) {
307
+ return computed(() => state.value?.sections ?? unref(fallback) ?? []);
308
+ }
309
+
310
+ /**
311
+ * Kept for compatibility. Prefer useStorefront().theme — it already layers
312
+ * this overlay over the saved settings, so it works in and out of preview.
313
+ */
314
+ const previewTheme = computed(() => previewThemeState.value);
315
+
316
+ return {
317
+ isPreview, state, device, selectedKey, hoveredKey, labels, overlayOn, previewTheme,
318
+ start, stop, send, sectionsFrom,
319
+ };
320
+ }
@@ -0,0 +1,107 @@
1
+ import {
2
+ BUTTON_HOVERS,
3
+ BUTTON_STYLES,
4
+ FONT_FAMILIES,
5
+ TEXT_TRANSFORMS,
6
+ color,
7
+ compact,
8
+ has,
9
+ letterSpacing,
10
+ pick,
11
+ px,
12
+ shadow,
13
+ weight,
14
+ } from "#osi/theme/controls";
15
+
16
+ /**
17
+ * A section's / block's BUTTON_* settings -> one button's overrides.
18
+ *
19
+ * Returns CSS custom properties (--btn-*), not properties. The element must
20
+ * carry the `osi-btn` class (see useButtonClasses below), which is where
21
+ * the look is actually drawn — from these variables first, then the theme's
22
+ * --osi-button-* set, then the colour scheme. So:
23
+ *
24
+ * - a setting the merchant filled in on this section wins,
25
+ * - a setting left empty follows the theme's Buttons group,
26
+ * - a colour left empty in both follows the section's colour scheme.
27
+ *
28
+ * Variables rather than inline `background:` so :hover still works — an
29
+ * inline property can't be overridden by a stylesheet hover rule, an inline
30
+ * variable can be read by one.
31
+ *
32
+ * const buttonStyle = useButtonStyle(() => s.value)
33
+ * const buttonClasses = useButtonClasses(() => s.value)
34
+ * <a class="my-cta" :class="buttonClasses" :style="buttonStyle">…</a>
35
+ *
36
+ * Takes a prefix so a section with two buttons (BUTTON_*, SECONDARY_BUTTON_*)
37
+ * can call it twice.
38
+ *
39
+ * `defaults` is accepted for backwards compatibility and ignored for size and
40
+ * padding: those used to be pinned here (15px / 12px 28px), which is exactly
41
+ * what stopped the theme's button size from reaching any section.
42
+ *
43
+ * @param {object|Ref|(() => object)} settings
44
+ * @param {object} [options]
45
+ * @param {string} [options.prefix='BUTTON']
46
+ * @returns {ComputedRef<object>}
47
+ */
48
+ export function useButtonStyle(settings, options = {}) {
49
+ const { prefix = "BUTTON" } = options;
50
+ return computed(() => buttonVars(toValue(settings) ?? {}, prefix));
51
+ }
52
+
53
+ /** Non-reactive form, for per-block buttons rendered in a v-for. */
54
+ export function buttonVars(s, prefix = "BUTTON") {
55
+ const get = (key) => s?.[`${prefix}_${key}`];
56
+
57
+ // BUTTON_SIZE is a px number on older sections and SMALL/MEDIUM/LARGE on
58
+ // newer ones; the enum is handled as a class in buttonClasses().
59
+ const size = get("SIZE");
60
+ const sizePx = has(size) && Number.isFinite(Number(size)) ? px(size) : undefined;
61
+
62
+ const shadowValue = has(get("SHADOW")) ? shadow(get("SHADOW"), get("SHADOW_COLOR")) : undefined;
63
+
64
+ return compact({
65
+ "--btn-bg": color(get("BG_COLOR")),
66
+ "--btn-fg": color(get("TEXT_COLOR")),
67
+ "--btn-border-color": color(get("BORDER_COLOR")),
68
+ "--btn-hover-bg": color(get("HOVER_BG_COLOR")),
69
+ "--btn-hover-fg": color(get("HOVER_TEXT_COLOR")),
70
+ "--btn-radius": px(get("RADIUS")),
71
+ "--btn-border-w": px(get("BORDER_WIDTH")),
72
+ "--btn-shadow": shadowValue,
73
+ "--btn-pad-y": px(get("PADDING_Y")),
74
+ "--btn-pad-x": px(get("PADDING_X")),
75
+ "--btn-fs": sizePx ?? px(get("FONT_SIZE")),
76
+ "--btn-fw": weight(get("FONT_WEIGHT") ?? get("WEIGHT")),
77
+ "--btn-ff": FONT_FAMILIES[get("FONT")],
78
+ "--btn-tt": TEXT_TRANSFORMS[get("TRANSFORM")],
79
+ "--btn-ls": letterSpacing(get("LETTER_SPACING")),
80
+ });
81
+ }
82
+
83
+ /**
84
+ * The class list that goes with useButtonStyle(): `osi-btn` plus the variant
85
+ * classes for whatever enum settings this section set. Unset style means the
86
+ * theme's BUTTON_STYLE applies (a root class does that), so no variant class
87
+ * is added for it.
88
+ */
89
+ export function useButtonClasses(settings, options = {}) {
90
+ const { prefix = "BUTTON", extra = [] } = options;
91
+ return computed(() => buttonClasses(toValue(settings) ?? {}, prefix, toValue(extra)));
92
+ }
93
+
94
+ export function buttonClasses(s, prefix = "BUTTON", extra = []) {
95
+ const get = (key) => s?.[`${prefix}_${key}`];
96
+ const style = pick(get("STYLE"), BUTTON_STYLES);
97
+ const hover = pick(get("HOVER_EFFECT"), BUTTON_HOVERS);
98
+ const size = pick(get("SIZE"), ["SMALL", "MEDIUM", "LARGE"]);
99
+ return [
100
+ "osi-btn",
101
+ style && `osi-btn--${style.toLowerCase()}`,
102
+ hover && `osi-btn-hover--${hover.toLowerCase()}`,
103
+ size && size !== "MEDIUM" && `osi-btn--${size === "SMALL" ? "sm" : "lg"}`,
104
+ get("WIDTH") === "FULL" && "osi-btn--block",
105
+ ...[].concat(extra ?? []),
106
+ ].filter(Boolean);
107
+ }