@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.
- package/README.md +80 -2
- package/THEME_DEV_API.md +80 -0
- package/app/app.vue +154 -0
- package/app/assets/css/osi-controls.css +824 -0
- package/app/components/SectionRenderer.vue +46 -0
- package/app/components/builder/BuilderOverlay.vue +582 -0
- package/app/components/checkout/ExtraFees.vue +329 -0
- package/app/components/checkout/PaymentHandoff.vue +388 -0
- package/app/components/common/Banner.vue +35 -0
- package/app/components/common/ConfirmDeleteModal.vue +117 -0
- package/app/components/common/DropdownSelect.vue +432 -0
- package/app/components/common/Loader.vue +32 -0
- package/app/components/common/OsiField.vue +218 -0
- package/app/components/common/PhoneNumInput.vue +434 -0
- package/app/composables/UseBodyScrollLock.js +122 -0
- package/app/composables/UseThemeGroup.js +575 -0
- package/app/composables/useAddToCart.js +87 -0
- package/app/composables/useAnalytics.js +15 -0
- package/app/composables/useAnnouncements.js +29 -0
- package/app/composables/useApiErrorToast.js +104 -0
- package/app/composables/useAuth.js +99 -0
- package/app/composables/useBanners.js +25 -0
- package/app/composables/useBuilderPreview.js +320 -0
- package/app/composables/useButtonStyle.js +107 -0
- package/app/composables/useCarousel.js +160 -0
- package/app/composables/useCart.js +109 -0
- package/app/composables/useCheckout.js +76 -0
- package/app/composables/useClassificationItems.js +269 -0
- package/app/composables/useClassificationPage.js +545 -0
- package/app/composables/useClassificationUrl.js +63 -0
- package/app/composables/useColorScheme.js +44 -0
- package/app/composables/useContactForm.js +53 -0
- package/app/composables/useContentTree.js +770 -0
- package/app/composables/useDragScroll.js +73 -0
- package/app/composables/useFooterSocials.js +107 -0
- package/app/composables/useInputStyle.js +45 -0
- package/app/composables/useLayoutShell.js +114 -0
- package/app/composables/useLazyVideo.js +36 -0
- package/app/composables/useMediaTypes.js +66 -0
- package/app/composables/useMediaUrl.js +71 -0
- package/app/composables/useMenu.js +100 -0
- package/app/composables/useNavStyle.js +219 -0
- package/app/composables/usePlaceholders.js +294 -0
- package/app/composables/usePolicies.js +26 -0
- package/app/composables/useProductCardMedia.js +135 -0
- package/app/composables/useProductCardStyle.js +132 -0
- package/app/composables/useProductDetail.js +982 -0
- package/app/composables/useProductGallery.js +166 -0
- package/app/composables/useProductItems.js +270 -0
- package/app/composables/useProductUrl.js +60 -0
- package/app/composables/useProductVariants.js +210 -0
- package/app/composables/useQuickShop.js +115 -0
- package/app/composables/useRelatedProducts.js +186 -0
- package/app/composables/useRenderContext.js +45 -0
- package/app/composables/useScopedCss.js +106 -0
- package/app/composables/useSearch.js +35 -0
- package/app/composables/useSectionData.js +462 -0
- package/app/composables/useSectionShell.js +113 -0
- package/app/composables/useSidebar.js +69 -0
- package/app/composables/useStoreData.js +57 -0
- package/app/composables/useStoreInit.js +52 -0
- package/app/composables/useStorefront.js +239 -0
- package/app/composables/useT.js +21 -0
- package/app/composables/useTextStyles.js +84 -0
- package/app/composables/useThemeControls.js +34 -0
- package/app/composables/useThemeSeo.js +91 -0
- package/app/composables/useThemeTokens.js +171 -0
- package/app/composables/useWishlist.js +66 -0
- package/app/logic/components/AnnouncementBar.js +118 -0
- package/app/logic/components/CardGrid.js +125 -0
- package/app/logic/components/CardList.js +98 -0
- package/app/logic/components/CartDrawer.js +273 -0
- package/app/logic/components/CartItem.js +31 -0
- package/app/logic/components/ClassificationBento.js +6 -0
- package/app/logic/components/ClassificationCard.js +6 -0
- package/app/logic/components/ClassificationCarousel.js +115 -0
- package/app/logic/components/ClassificationEditorial.js +41 -0
- package/app/logic/components/ClassificationFeatured.js +57 -0
- package/app/logic/components/ClassificationGrid.js +6 -0
- package/app/logic/components/ClassificationNav.js +61 -0
- package/app/logic/components/ClassificationPart.js +72 -0
- package/app/logic/components/FooterBlock.js +91 -0
- package/app/logic/components/FooterSocials.js +60 -0
- package/app/logic/components/HeaderDropdownList.js +29 -0
- package/app/logic/components/HeaderIcon.js +68 -0
- package/app/logic/components/HeaderLogo.js +39 -0
- package/app/logic/components/HeaderNav.js +268 -0
- package/app/logic/components/MobileMenu.js +280 -0
- package/app/logic/components/ProductBuyButtons.js +66 -0
- package/app/logic/components/ProductCard.js +160 -0
- package/app/logic/components/ProductFilters.js +37 -0
- package/app/logic/components/ProductGallery.js +19 -0
- package/app/logic/components/ProductInfoBlock.js +411 -0
- package/app/logic/components/ProductLightbox.js +12 -0
- package/app/logic/components/ProductQuantity.js +31 -0
- package/app/logic/components/ProductVariantPicker.js +125 -0
- package/app/logic/components/QuickBuy.js +194 -0
- package/app/logic/components/SearchOverlay.js +41 -0
- package/app/logic/components/SidebarMenuItem.js +39 -0
- package/app/logic/components/SiteFooter.js +476 -0
- package/app/logic/components/SiteHeader.js +266 -0
- package/app/logic/components/WhatsAppButton.js +75 -0
- package/app/logic/layouts/default.js +66 -0
- package/app/logic/sections/AnnouncementBarSection.js +6 -0
- package/app/logic/sections/BlockCarousel.js +103 -0
- package/app/logic/sections/ClassificationListSection.js +40 -0
- package/app/logic/sections/ContactFormSection.js +294 -0
- package/app/logic/sections/ContactInfoSection.js +419 -0
- package/app/logic/sections/ContentNode.js +189 -0
- package/app/logic/sections/CountdownSection.js +254 -0
- package/app/logic/sections/CustomHtmlSection.js +51 -0
- package/app/logic/sections/DividerSection.js +31 -0
- package/app/logic/sections/FaqSection.js +53 -0
- package/app/logic/sections/FeatureListSection.js +6 -0
- package/app/logic/sections/FeaturedProductSection.js +40 -0
- package/app/logic/sections/FooterSection.js +16 -0
- package/app/logic/sections/FooterUtilitiesSection.js +24 -0
- package/app/logic/sections/GallerySection.js +232 -0
- package/app/logic/sections/HeaderSection.js +24 -0
- package/app/logic/sections/HeroSection.js +24 -0
- package/app/logic/sections/LogoListSection.js +6 -0
- package/app/logic/sections/MainClassificationSection.js +473 -0
- package/app/logic/sections/MainProductSection.js +211 -0
- package/app/logic/sections/MainSearchSection.js +6 -0
- package/app/logic/sections/MapSection.js +92 -0
- package/app/logic/sections/MediaContentSection.js +38 -0
- package/app/logic/sections/MediaWithTextSection.js +238 -0
- package/app/logic/sections/NewsletterSection.js +126 -0
- package/app/logic/sections/ProductHighlightSection.js +201 -0
- package/app/logic/sections/ProductListSection.js +45 -0
- package/app/logic/sections/ProductRecommendationsSection.js +113 -0
- package/app/logic/sections/RichTextSection.js +6 -0
- package/app/logic/sections/ScrollingTextSection.js +69 -0
- package/app/logic/sections/SearchHeaderSection.js +22 -0
- package/app/logic/sections/SlideshowSection.js +118 -0
- package/app/logic/sections/SpacerSection.js +37 -0
- package/app/logic/sections/SplitShowcaseSection.js +6 -0
- package/app/logic/sections/TestimonialListSection.js +6 -0
- package/app/logic/sections/VideoSection.js +235 -0
- package/app/pages/[category]/[product].vue +196 -0
- package/app/pages/__builder-preview.vue +38 -0
- package/app/pages/categories/[slug].vue +208 -0
- package/app/pages/checkout.vue +1972 -0
- package/app/pages/forgot-password.vue +115 -0
- package/app/pages/index.vue +64 -0
- package/app/pages/login.vue +585 -0
- package/app/pages/order-detail.vue +442 -0
- package/app/pages/pages/[handle].vue +58 -0
- package/app/pages/privacy-policy.vue +46 -0
- package/app/pages/resetpassword.vue +138 -0
- package/app/pages/resetverificationcode.vue +163 -0
- package/app/pages/result.vue +203 -0
- package/app/pages/return-policy.vue +50 -0
- package/app/pages/search.vue +100 -0
- package/app/pages/shipping-policy.vue +46 -0
- package/app/pages/user-profile/components/AddAddressModal.vue +452 -0
- package/app/pages/user-profile/components/Whishlist.vue +137 -0
- package/app/pages/user-profile/components/left-bar.vue +233 -0
- package/app/pages/user-profile/components/orders.vue +162 -0
- package/app/pages/user-profile/components/profile-details.vue +566 -0
- package/app/pages/user-profile/components/settings.vue +194 -0
- package/app/pages/user-profile/index.vue +252 -0
- package/app/pages/verificationcode.vue +188 -0
- package/app/plugins/analytics.client.js +99 -0
- package/app/plugins/geo.ts +24 -0
- package/app/plugins/reveal.client.js +53 -0
- package/app/plugins/toast.js +10 -0
- package/app/plugins/vue-query.ts +5 -0
- package/app/services/ApiServices.js +74 -0
- package/app/services/AuthBus.js +162 -0
- package/app/services/Authentication/AuthenticationService.js +133 -0
- package/app/services/Authentication/TokenService.js +62 -0
- package/app/services/CartServices.js +91 -0
- package/app/services/CartToken.js +26 -0
- package/app/services/CategoryServices.js +54 -0
- package/app/services/CheckoutServices.js +113 -0
- package/app/services/HomeServices.js +133 -0
- package/app/services/NewsletterServices.js +29 -0
- package/app/services/PaymentServices.js +71 -0
- package/app/services/PoliciesServices.js +16 -0
- package/app/services/ProductServices.js +79 -0
- package/app/services/SearchServices.js +18 -0
- package/app/services/UserServices.js +105 -0
- package/app/services/UtilsServices.js +46 -0
- package/app/services/WishlistServices.js +51 -0
- package/app/services/storefront.service.js +57 -0
- package/app/stores/userStore.js +13 -0
- package/app/theme/categoryTree.js +68 -0
- package/app/theme/controls.js +88 -0
- package/app/theme/registry.js +56 -0
- package/app/utils/analytics.js +864 -0
- package/app/utils/device.js +50 -0
- package/app/utils/eventBus.js +24 -0
- package/app/utils/icon-presets.js +24 -0
- package/app/utils/productOffers.js +118 -0
- package/app/utils/requestCache.js +36 -0
- package/app/utils/scrollToError.js +59 -0
- package/app/utils/toPlainError.js +30 -0
- package/bin/osimart-theme.mjs +259 -0
- package/nuxt/osi-sections.ts +56 -0
- package/nuxt.config.ts +60 -0
- package/package.json +43 -3
- package/public/css/input.css +29 -0
- package/public/css/output.css +2716 -0
- package/public/favicon-32x32.png +0 -0
- 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
|
+
}
|