@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,770 @@
|
|
|
1
|
+
import { markRaw } from "vue";
|
|
2
|
+
/**
|
|
3
|
+
* The block tree of a block-built section (MEDIA_WITH_TEXT, VIDEO, …).
|
|
4
|
+
*
|
|
5
|
+
* Blocks nest: block.parent_key names the GROUP a block sits in ('' = the
|
|
6
|
+
* section). Order is the section's flat block order. This turns the flat list
|
|
7
|
+
* into resolved, pruned nodes; ContentNode.vue draws them. The root node is a
|
|
8
|
+
* flex container laid out by the section's own DIRECTION / VERTICAL_ON_MOBILE /
|
|
9
|
+
* ALIGNMENT / POSITION / GAP settings.
|
|
10
|
+
*
|
|
11
|
+
* Pruned (never rendered): disabled blocks and their subtrees, blocks whose
|
|
12
|
+
* group isn't in the payload, empty text, a button without label or link, an
|
|
13
|
+
* image without media, a group left with no children and no background media.
|
|
14
|
+
* In the builder editor, images and groups are kept with placeholders so there
|
|
15
|
+
* is something to click.
|
|
16
|
+
*
|
|
17
|
+
* @param section getter / ref of the section
|
|
18
|
+
* @param options rootDefaults settings the root falls back to (e.g. its
|
|
19
|
+
* DIRECTION when the section never set one)
|
|
20
|
+
* fillRootImages getter: images directly in the section fill
|
|
21
|
+
* its height instead of setting it
|
|
22
|
+
* index getter: the section's index (first image on
|
|
23
|
+
* the first section loads eagerly)
|
|
24
|
+
* imageHeight px an Image block falls back to when it sets
|
|
25
|
+
* no IMAGE_HEIGHT of its own
|
|
26
|
+
*
|
|
27
|
+
* Returns { tree, backgroundOf, editing, u } — `u` holds the small value
|
|
28
|
+
* helpers so sections using this don't redefine them.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* options.custom { BLOCK_TYPE: Component } — block types a section draws
|
|
32
|
+
* with components of its own (see the default case below).
|
|
33
|
+
* options.customText { BLOCK_TYPE: default preset } — which of those take the
|
|
34
|
+
* Text block's look (preset, size, colour…).
|
|
35
|
+
* options.raw [BLOCK_TYPE] — kept as plain nodes (with their children)
|
|
36
|
+
* for the section to draw itself.
|
|
37
|
+
*/
|
|
38
|
+
export function useContentTree(section, options = {}) {
|
|
39
|
+
const t = useT();
|
|
40
|
+
const s = computed(() => toValue(section)?.settings ?? {});
|
|
41
|
+
const { mediaUrl } = useMediaUrl();
|
|
42
|
+
const { isVideo } = useMediaTypes();
|
|
43
|
+
const { isMobile } = useLayoutShell();
|
|
44
|
+
const placeholders = usePlaceholders();
|
|
45
|
+
const editing = computed(() => !!placeholders.allowed?.value);
|
|
46
|
+
|
|
47
|
+
const has = (v) => v !== undefined && v !== null && v !== "";
|
|
48
|
+
const num = (v, d) => (has(v) && Number.isFinite(Number(v)) ? Number(v) : d);
|
|
49
|
+
const px = (v) => (has(v) && Number.isFinite(Number(v)) ? `${Number(v)}px` : undefined);
|
|
50
|
+
const compact = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => has(v)));
|
|
51
|
+
const upper = (v, d) => String(v || d).toUpperCase();
|
|
52
|
+
const pathOf = (m) => m?.path ?? (typeof m === "string" ? m : "");
|
|
53
|
+
|
|
54
|
+
/* ------------------------------------------------------------ shared bits */
|
|
55
|
+
|
|
56
|
+
const ROW_JUSTIFY = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end", SPACE_BETWEEN: "space-between" };
|
|
57
|
+
const COL_ALIGN = { LEFT: "flex-start", CENTER: "center", RIGHT: "flex-end", SPACE_BETWEEN: "flex-start" };
|
|
58
|
+
const CROSS = { TOP: "flex-start", CENTER: "center", BOTTOM: "flex-end" };
|
|
59
|
+
const TEXT_ALIGN = { LEFT: "start", CENTER: "center", RIGHT: "end", SPACE_BETWEEN: "start" };
|
|
60
|
+
|
|
61
|
+
/** Container classes + vars from DIRECTION / ALIGNMENT / POSITION / GAP. */
|
|
62
|
+
function flexOf(v) {
|
|
63
|
+
const row = upper(v.DIRECTION, "VERTICAL") === "HORIZONTAL";
|
|
64
|
+
// SPACE_BETWEEN is a row-only choice (the panel only offers it then); a
|
|
65
|
+
// column that still has it stored lines up left, as the panel shows.
|
|
66
|
+
const chosen = upper(v.ALIGNMENT, "LEFT");
|
|
67
|
+
const align = !row && chosen === "SPACE_BETWEEN" ? "LEFT" : chosen;
|
|
68
|
+
const position = upper(v.POSITION, "CENTER");
|
|
69
|
+
// CAROUSEL (horizontal only): MOBILE swipes on phones and is a normal row
|
|
70
|
+
// above; ALWAYS swipes everywhere. Either one replaces stacking on phones.
|
|
71
|
+
const mode = row ? upper(v.CAROUSEL, "NONE") : "NONE";
|
|
72
|
+
const carousel = mode === "MOBILE" || mode === "ALWAYS";
|
|
73
|
+
return {
|
|
74
|
+
flexClasses: [
|
|
75
|
+
row ? "is-row" : "is-col",
|
|
76
|
+
{
|
|
77
|
+
// WRAP: a row that continues on the next line instead of stacking.
|
|
78
|
+
"is-wrap": row && !carousel && !!v.WRAP,
|
|
79
|
+
"stack-mobile": row && !carousel && !v.WRAP && v.VERTICAL_ON_MOBILE !== false,
|
|
80
|
+
"car-mobile": mode === "MOBILE",
|
|
81
|
+
"car-always": mode === "ALWAYS",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
carousel: carousel
|
|
85
|
+
? {
|
|
86
|
+
arrows: v.CAROUSEL_ARROWS !== false,
|
|
87
|
+
dots: v.CAROUSEL_DOTS !== false,
|
|
88
|
+
autoplay: !!v.CAROUSEL_AUTOPLAY,
|
|
89
|
+
interval: num(v.CAROUSEL_INTERVAL, 5) * 1000,
|
|
90
|
+
loop: v.CAROUSEL_LOOP !== false,
|
|
91
|
+
// Where the controls sit, and how they look.
|
|
92
|
+
arrowsAt: upper(v.CAROUSEL_ARROWS_POSITION, "BELOW") === "SIDES" ? "SIDES" : "BELOW",
|
|
93
|
+
dotsAt: upper(v.CAROUSEL_DOTS_POSITION, "BELOW") === "INSIDE" ? "INSIDE" : "BELOW",
|
|
94
|
+
dotStyle: ["BAR", "LINE"].includes(upper(v.CAROUSEL_DOT_STYLE, "DOT"))
|
|
95
|
+
? upper(v.CAROUSEL_DOT_STYLE, "DOT")
|
|
96
|
+
: "DOT",
|
|
97
|
+
arrowBorder: v.CAROUSEL_ARROW_BORDER !== false,
|
|
98
|
+
controlVars: compact({
|
|
99
|
+
"--bc-arrow": px(v.CAROUSEL_ARROW_SIZE),
|
|
100
|
+
"--bc-arrow-color": v.CAROUSEL_ARROW_COLOR,
|
|
101
|
+
"--bc-arrow-bg": v.CAROUSEL_ARROW_BG,
|
|
102
|
+
"--bc-arrow-border": v.CAROUSEL_ARROW_BORDER_COLOR,
|
|
103
|
+
"--bc-arrow-radius": px(v.CAROUSEL_ARROW_RADIUS),
|
|
104
|
+
"--bc-arrow-offset": px(v.CAROUSEL_ARROW_OFFSET),
|
|
105
|
+
"--bc-dot": px(v.CAROUSEL_DOT_SIZE),
|
|
106
|
+
"--bc-dot-color": v.CAROUSEL_DOT_COLOR,
|
|
107
|
+
"--bc-dot-active": v.CAROUSEL_DOT_ACTIVE_COLOR,
|
|
108
|
+
"--bc-controls-gap": px(v.CAROUSEL_CONTROLS_SPACING),
|
|
109
|
+
}),
|
|
110
|
+
}
|
|
111
|
+
: null,
|
|
112
|
+
flexVars: compact({
|
|
113
|
+
"--cb-per": carousel ? String(num(v.PER_VIEW, 3)) : undefined,
|
|
114
|
+
"--cb-per-m": carousel ? String(num(v.PER_VIEW_MOBILE, 1.2)) : undefined,
|
|
115
|
+
// Both always set — a nested group must not inherit its parent's gap.
|
|
116
|
+
"--cb-gap": `${num(v.GAP, 16)}px`,
|
|
117
|
+
"--cb-gap-m": `${num(v.GAP_MOBILE, num(v.GAP, 16))}px`,
|
|
118
|
+
"--cb-row-justify": ROW_JUSTIFY[align],
|
|
119
|
+
"--cb-row-align": CROSS[position],
|
|
120
|
+
"--cb-col-align": COL_ALIGN[align],
|
|
121
|
+
"--cb-col-justify": CROSS[position],
|
|
122
|
+
"--cb-text-align": TEXT_ALIGN[align],
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Width classes. Desktop from `id` (FIT / FILL / CUSTOM + `${id}_CUSTOM` %);
|
|
129
|
+
* phones from `mobileId` when the block has one, else the desktop mode
|
|
130
|
+
* (a custom width becomes fill once a row stacks).
|
|
131
|
+
*/
|
|
132
|
+
// Width shares are shortcuts for a custom %.
|
|
133
|
+
const SHARES = { QUARTER: 25, THIRD: 100 / 3, HALF: 50, TWO_THIRDS: 200 / 3, THREE_QUARTERS: 75 };
|
|
134
|
+
|
|
135
|
+
/** FIT / FILL as is; a share or CUSTOM → { mode: "CUSTOM", pct }. */
|
|
136
|
+
function widthOf(v, id, fallback) {
|
|
137
|
+
const mode = upper(v[id], fallback);
|
|
138
|
+
if (SHARES[mode]) return { mode: "CUSTOM", pct: SHARES[mode] };
|
|
139
|
+
if (mode === "CUSTOM") return { mode, pct: num(v[`${id}_CUSTOM`], 50) };
|
|
140
|
+
return { mode: mode === "FIT" ? "FIT" : "FILL" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sizeOf(v, id, mobileId, fallback = "FILL") {
|
|
144
|
+
const desk = widthOf(v, id, fallback);
|
|
145
|
+
// No mobile setting: a custom / share width becomes fill once a row stacks.
|
|
146
|
+
const mob = mobileId ? widthOf(v, mobileId, fallback) : { mode: desk.mode === "CUSTOM" ? "FILL" : desk.mode };
|
|
147
|
+
return {
|
|
148
|
+
sizeClasses: [`w-${desk.mode.toLowerCase()}`, `mw-${mob.mode.toLowerCase()}`],
|
|
149
|
+
sizeVars: compact({
|
|
150
|
+
"--cb-w": desk.pct != null ? `${desk.pct}%` : undefined,
|
|
151
|
+
"--cb-mw": mob.pct != null ? `${mob.pct}%` : undefined,
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Padding as variables, desktop and phone, like the section's own
|
|
158
|
+
* (PADDING_* and PADDING_*_MOBILE; an empty phone value = the desktop one).
|
|
159
|
+
* ContentNode's CSS picks the pair per breakpoint. All eight are always set:
|
|
160
|
+
* custom properties inherit, so a block that left one out would pick up its
|
|
161
|
+
* group's padding.
|
|
162
|
+
*/
|
|
163
|
+
const SIDES = { TOP: "t", RIGHT: "r", BOTTOM: "b", LEFT: "l" };
|
|
164
|
+
function paddingOf(v) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const [side, k] of Object.entries(SIDES)) {
|
|
167
|
+
const desktop = num(v[`PADDING_${side}`], 0);
|
|
168
|
+
out[`--cb-p${k}`] = `${desktop}px`;
|
|
169
|
+
out[`--cb-p${k}-m`] = `${num(v[`PADDING_${side}_MOBILE`], desktop)}px`;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Background, border, corner radius, padding. */
|
|
175
|
+
function boxOf(v, { background = true } = {}) {
|
|
176
|
+
return {
|
|
177
|
+
...compact({
|
|
178
|
+
background: background ? v.BG_COLOR : undefined,
|
|
179
|
+
border: upper(v.BORDER_STYLE, "NONE") === "SOLID"
|
|
180
|
+
? `${num(v.BORDER_WIDTH, 1)}px solid ${v.BORDER_COLOR || "currentColor"}`
|
|
181
|
+
: undefined,
|
|
182
|
+
borderRadius: num(v.RADIUS, 0) ? `${num(v.RADIUS, 0)}px` : undefined,
|
|
183
|
+
}),
|
|
184
|
+
...paddingOf(v),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function backgroundOf(v) {
|
|
189
|
+
const kind = upper(v.BACKGROUND_TYPE, "NONE");
|
|
190
|
+
const image = (isMobile.value && v.BACKGROUND_MOBILE_IMAGE) || v.BACKGROUND_IMAGE;
|
|
191
|
+
const src = kind === "IMAGE" ? mediaUrl(image ?? null) : kind === "VIDEO" ? mediaUrl(v.BACKGROUND_VIDEO_MEDIA ?? null) : "";
|
|
192
|
+
const opacity = num(v.OVERLAY_OPACITY, 0);
|
|
193
|
+
return {
|
|
194
|
+
bg: src ? { kind: kind === "VIDEO" ? "video" : "image", src } : null,
|
|
195
|
+
overlay: opacity > 0 ? { background: v.OVERLAY_COLOR || "#000", opacity: opacity / 100 } : null,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const RATIOS = { PORTRAIT: "4 / 5", SQUARE: "1 / 1", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
|
|
200
|
+
const MAX_WIDTHS = { NARROW: "28rem", NORMAL: "42rem", NONE: "none" };
|
|
201
|
+
const WEIGHTS = { LIGHT: 300, REGULAR: 400, MEDIUM: 500, SEMIBOLD: 600, BOLD: 700 };
|
|
202
|
+
const CASES = { NONE: "none", UPPERCASE: "uppercase", LOWERCASE: "lowercase", CAPITALIZE: "capitalize" };
|
|
203
|
+
const CARD_RATIOS = { SQUARE: "1 / 1", PORTRAIT_4_5: "4 / 5", PORTRAIT_3_4: "3 / 4", LANDSCAPE: "4 / 3", WIDE: "16 / 9" };
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A Text block's look — element, preset classes, custom typography — for
|
|
207
|
+
* Text itself and for text-like card parts (title, count, description).
|
|
208
|
+
*/
|
|
209
|
+
function textLook(v, defaultPreset = "DEFAULT") {
|
|
210
|
+
const preset = upper(v.PRESET, defaultPreset);
|
|
211
|
+
const heading = HEADINGS.includes(preset);
|
|
212
|
+
// The element: a heading preset is its own tag; CUSTOM uses TAG.
|
|
213
|
+
const customTag = preset === "CUSTOM" ? upper(v.TAG, "P") : "";
|
|
214
|
+
const tag = heading ? preset : HEADINGS.includes(customTag) ? customTag : "";
|
|
215
|
+
return {
|
|
216
|
+
tag: tag ? tag.toLowerCase() : "div",
|
|
217
|
+
presetClass: heading ? ["p-heading", `p-${preset.toLowerCase()}`] : [`p-${preset.toLowerCase()}`],
|
|
218
|
+
typeVars: compact({
|
|
219
|
+
"--cb-max": MAX_WIDTHS[upper(v.MAX_WIDTH, "NORMAL")],
|
|
220
|
+
"--cb-align": TEXT_ALIGN[upper(v.ALIGNMENT, "")],
|
|
221
|
+
"--cb-color": v.COLOR,
|
|
222
|
+
...(preset === "CUSTOM"
|
|
223
|
+
? {
|
|
224
|
+
"--cb-ff": v.FONT === "HEADING" ? "var(--osi-font-heading)" : undefined,
|
|
225
|
+
"--cb-fs": px(v.SIZE),
|
|
226
|
+
"--cb-fs-m": px(v.SIZE_MOBILE),
|
|
227
|
+
"--cb-fw": Number(v.WEIGHT) || WEIGHTS[upper(v.WEIGHT, "")],
|
|
228
|
+
"--cb-lh": has(v.LINE_HEIGHT) ? String(num(v.LINE_HEIGHT, 150) / 100) : undefined,
|
|
229
|
+
"--cb-ls": has(v.LETTER_SPACING) ? `${num(v.LETTER_SPACING, 0) / 100}em` : undefined,
|
|
230
|
+
"--cb-tt": CASES[v.CASE],
|
|
231
|
+
}
|
|
232
|
+
: {}),
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const SPACES = { SMALL: 12, MEDIUM: 24, LARGE: 48 };
|
|
238
|
+
const HEADINGS = ["H1", "H2", "H3", "H4", "H5", "H6"];
|
|
239
|
+
|
|
240
|
+
const plain = (html) => String(html || "").replace(/<(img|video|iframe)\b/gi, "x").replace(/<[^>]*>/g, "").replace(/ /g, " ").trim();
|
|
241
|
+
// A heading preset renders as <hN>: its richtext paragraphs become lines.
|
|
242
|
+
const inline = (html) =>
|
|
243
|
+
String(html).replace(/<\/p>\s*<p[^>]*>/gi, "<br>").replace(/^\s*<p[^>]*>/i, "").replace(/<\/p>\s*$/i, "");
|
|
244
|
+
|
|
245
|
+
/* ------------------------------------------------------------ the tree */
|
|
246
|
+
|
|
247
|
+
let firstImage = true;
|
|
248
|
+
|
|
249
|
+
/** Block types that hold other blocks (their children point at them). */
|
|
250
|
+
// PRODUCT_GRID: the classification page's grid, holding its Card template.
|
|
251
|
+
const CONTAINERS = new Set(["GROUP", "ACCORDION", "SLIDE", "CARD", "PRODUCT_GRID"]);
|
|
252
|
+
|
|
253
|
+
/** Root-level images fill the container's height (a sized section). */
|
|
254
|
+
const sized = computed(() => !!toValue(options.fillRootImages));
|
|
255
|
+
|
|
256
|
+
function resolve(block, children, atRoot = false) {
|
|
257
|
+
const v = block.settings ?? {};
|
|
258
|
+
const base = { key: block.id ?? block.key, type: block.type, s: v, children };
|
|
259
|
+
|
|
260
|
+
switch (block.type) {
|
|
261
|
+
// A Slide is a Group with a background — drawn as one (type GROUP), marked
|
|
262
|
+
// `slide` so the slideshow can find it.
|
|
263
|
+
case "SLIDE":
|
|
264
|
+
case "GROUP": {
|
|
265
|
+
// An empty group is dropped; an empty slide still shows its background.
|
|
266
|
+
if (!children.length && !editing.value && block.type !== "SLIDE" && upper(v.BACKGROUND_TYPE, "NONE") === "NONE") return null;
|
|
267
|
+
const heightMode = upper(v.HEIGHT, "FIT");
|
|
268
|
+
const size = sizeOf(v, "WIDTH");
|
|
269
|
+
const nestedLink = children.some(function linked(c) {
|
|
270
|
+
return c.link || c.type === "BUTTON" || /<a\b/i.test(c.html || "") || c.children?.some(linked);
|
|
271
|
+
});
|
|
272
|
+
const background = backgroundOf(v);
|
|
273
|
+
return {
|
|
274
|
+
...base,
|
|
275
|
+
type: "GROUP",
|
|
276
|
+
slide: block.type === "SLIDE",
|
|
277
|
+
...flexOf(v),
|
|
278
|
+
...background,
|
|
279
|
+
clip: !!(num(v.RADIUS, 0) || background.bg),
|
|
280
|
+
sizeClasses: [...size.sizeClasses, `h-${heightMode.toLowerCase()}`, { "is-sticky": !!v.STICKY }],
|
|
281
|
+
sizeVars: {
|
|
282
|
+
...size.sizeVars,
|
|
283
|
+
...(heightMode === "CUSTOM" ? { "--cb-h": `${num(v.HEIGHT_CUSTOM, 300)}px` } : {}),
|
|
284
|
+
// Phones: HEIGHT_MOBILE, else the custom height. Always set, so a
|
|
285
|
+
// nested group never inherits its parent's.
|
|
286
|
+
"--cb-h-m": `${num(v.HEIGHT_MOBILE, heightMode === "CUSTOM" ? num(v.HEIGHT_CUSTOM, 300) : 0)}px`,
|
|
287
|
+
},
|
|
288
|
+
// COLOR: text colour for everything inside (inherited).
|
|
289
|
+
box: { ...boxOf(v), ...compact({ color: v.COLOR }) },
|
|
290
|
+
link: v.LINK_URL && !nestedLink ? v.LINK_URL : null,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "MEDIA": {
|
|
295
|
+
// In a section with a set height, an image sitting directly in the
|
|
296
|
+
// section fills that height (cropped) instead of forcing its own —
|
|
297
|
+
// otherwise its aspect ratio decides the height and the setting does
|
|
298
|
+
// nothing.
|
|
299
|
+
const media = isMobile.value ? v.MOBILE_MEDIA || v.MEDIA || block.media : v.MEDIA ?? block.media ?? block.relations?.media;
|
|
300
|
+
const src = mediaUrl(media ?? null);
|
|
301
|
+
if (!src && !editing.value) return null;
|
|
302
|
+
const ratio = upper(v.RATIO, "SQUARE");
|
|
303
|
+
// A section can give images a height to fall back on (the marquee: an
|
|
304
|
+
// item has no row to take a width from, so it's sized by height).
|
|
305
|
+
const imageHeight = has(v.IMAGE_HEIGHT) ? v.IMAGE_HEIGHT : toValue(options.imageHeight);
|
|
306
|
+
const eager = !!src && firstImage && toValue(options.index) === 0;
|
|
307
|
+
if (src) firstImage = false;
|
|
308
|
+
return {
|
|
309
|
+
...base,
|
|
310
|
+
...sizeOf(v, "DESKTOP_WIDTH", "MOBILE_WIDTH"),
|
|
311
|
+
box: boxOf(v, { background: false }),
|
|
312
|
+
frame: compact({
|
|
313
|
+
"--cb-ratio": RATIOS[ratio],
|
|
314
|
+
"--cb-fit": upper(v.FIT, "COVER").toLowerCase(),
|
|
315
|
+
"--cb-max-h": px(v.MAX_HEIGHT),
|
|
316
|
+
"--cb-max-h-m": px(v.MAX_HEIGHT_MOBILE),
|
|
317
|
+
"--cb-img-h": px(imageHeight),
|
|
318
|
+
"--cb-img-h-m": px(v.IMAGE_HEIGHT_MOBILE),
|
|
319
|
+
borderRadius: px(v.RADIUS),
|
|
320
|
+
}),
|
|
321
|
+
fillHeight: atRoot && sized.value && !has(imageHeight),
|
|
322
|
+
fixedHeight: has(imageHeight),
|
|
323
|
+
grayscale: !!v.GRAYSCALE,
|
|
324
|
+
ratio: ratio === "AUTO" ? "auto" : ratio,
|
|
325
|
+
src,
|
|
326
|
+
isVideo: !!src && isVideo(pathOf(media)),
|
|
327
|
+
autoplay: v.VIDEO_AUTOPLAY !== false,
|
|
328
|
+
controls: !!v.VIDEO_CONTROLS,
|
|
329
|
+
alt: t(v.ALT) || "",
|
|
330
|
+
link: v.LINK_URL || null,
|
|
331
|
+
eager,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
case "TEXT": {
|
|
336
|
+
const html = t(v.TEXT);
|
|
337
|
+
if (!plain(html)) return null;
|
|
338
|
+
const look = textLook(v);
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
341
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
342
|
+
box: boxOf(v),
|
|
343
|
+
...look,
|
|
344
|
+
// In an <hN>, richtext paragraphs become lines (no <p> in a heading).
|
|
345
|
+
html: look.tag !== "div" ? inline(html) : html,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ---- CLASSIFICATION_LIST's / PRODUCT_LIST's card template: drawn once per item, filled
|
|
350
|
+
in from it by ContentNode (its `item` prop). Never pruned. ---- */
|
|
351
|
+
|
|
352
|
+
case "CARD": {
|
|
353
|
+
const placement = upper(v.TEXT_PLACEMENT, "BELOW") === "ON_IMAGE" ? "on-image" : "below";
|
|
354
|
+
let kids = children;
|
|
355
|
+
let box = { ...boxOf(v), ...compact({ color: v.COLOR }) };
|
|
356
|
+
// Buy buttons set to "On the image" draw inside the image (its
|
|
357
|
+
// `overlays`), not in the card's flow. No image: they stay in flow.
|
|
358
|
+
// Desktop and phones can differ: a button on the image on one and in
|
|
359
|
+
// the card on the other is drawn in both spots, each shown only on
|
|
360
|
+
// its device (`showOn`).
|
|
361
|
+
const cardImage = kids.find((c) => c.type === "CARD_IMAGE");
|
|
362
|
+
if (placement === "below" && cardImage) {
|
|
363
|
+
const overlays = [];
|
|
364
|
+
const flow = [];
|
|
365
|
+
for (const c of kids) {
|
|
366
|
+
const desk = !!c.onImage;
|
|
367
|
+
const phone = c.onImageMobile ?? desk;
|
|
368
|
+
if (!desk && !phone) flow.push(c);
|
|
369
|
+
else if (desk && phone) overlays.push(c);
|
|
370
|
+
else {
|
|
371
|
+
overlays.push({ ...c, showOn: desk ? "desktop" : "mobile" });
|
|
372
|
+
flow.push({ ...c, showOn: desk ? "mobile" : "desktop" });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (overlays.length) {
|
|
376
|
+
kids = flow.map((c) => (c === cardImage ? { ...c, overlays } : c));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (placement === "on-image") {
|
|
380
|
+
// Image underneath, everything else in one layer over it, placed by
|
|
381
|
+
// TEXT_POSITION; the card's padding moves onto that layer.
|
|
382
|
+
const image = children.find((c) => c.type === "CARD_IMAGE");
|
|
383
|
+
const rest = children.filter((c) => c !== image);
|
|
384
|
+
const layer = {
|
|
385
|
+
key: `${base.key}~on-image`,
|
|
386
|
+
type: "GROUP",
|
|
387
|
+
virtual: true,
|
|
388
|
+
s: {},
|
|
389
|
+
children: rest,
|
|
390
|
+
...flexOf({ DIRECTION: "VERTICAL", ALIGNMENT: v.ALIGNMENT, POSITION: v.TEXT_POSITION || "BOTTOM", GAP: v.GAP }),
|
|
391
|
+
sizeClasses: ["w-fill", "osi-cb-card-layer"],
|
|
392
|
+
sizeVars: {},
|
|
393
|
+
box: paddingOf(v),
|
|
394
|
+
bg: null,
|
|
395
|
+
overlay: null,
|
|
396
|
+
link: null,
|
|
397
|
+
clip: false,
|
|
398
|
+
};
|
|
399
|
+
kids = image ? [image, layer] : [layer];
|
|
400
|
+
box = { ...box, ...paddingOf({}) };
|
|
401
|
+
}
|
|
402
|
+
// Image to the card's edges (IMAGE_EDGE, off by default): the image
|
|
403
|
+
// cancels the card's padding with negative margins, so the padding
|
|
404
|
+
// goes around the text only. Needs the padding under names the image
|
|
405
|
+
// doesn't reset (--card-*), since its own --cb-p* are zero.
|
|
406
|
+
const bleed = placement === "below" && !!cardImage && v.IMAGE_EDGE === true;
|
|
407
|
+
// ALIGN_BUTTONS (on by default): the card fills its cell — every card
|
|
408
|
+
// in a row as tall as the tallest — and the first part holding a
|
|
409
|
+
// button is pushed to the bottom, taking what follows with it. So a
|
|
410
|
+
// one-line title and a two-line one still put their buttons on one
|
|
411
|
+
// line. Off for a lone card (Product highlight).
|
|
412
|
+
const alignButtons =
|
|
413
|
+
placement === "below" &&
|
|
414
|
+
v.ALIGN_BUTTONS !== false &&
|
|
415
|
+
toValue(section)?.type !== "PRODUCT_HIGHLIGHT";
|
|
416
|
+
if (alignButtons) {
|
|
417
|
+
const isButton = (c) => c.type === "CARD_BUTTON" || c.type === "BUTTON";
|
|
418
|
+
const holdsButton = (c) => isButton(c) || (c.children ?? []).some(holdsButton);
|
|
419
|
+
const at = kids.findIndex((c) => c.type !== "CARD_IMAGE" && holdsButton(c));
|
|
420
|
+
if (at >= 0) kids = kids.map((c, i) => (i === at ? { ...c, pushDown: true } : c));
|
|
421
|
+
}
|
|
422
|
+
if (bleed) {
|
|
423
|
+
for (const [k, val] of Object.entries(paddingOf(v))) box[k.replace("--cb-p", "--card-p")] = val;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...base,
|
|
427
|
+
...flexOf(v),
|
|
428
|
+
children: kids,
|
|
429
|
+
sizeClasses: ["w-fill"],
|
|
430
|
+
sizeVars: {},
|
|
431
|
+
box,
|
|
432
|
+
cardClasses: [
|
|
433
|
+
...(bleed ? ["image-bleed"] : []),
|
|
434
|
+
...(alignButtons ? ["align-buttons"] : []),
|
|
435
|
+
`card-${placement}`,
|
|
436
|
+
`hover-${upper(v.HOVER, "ZOOM").toLowerCase()}`,
|
|
437
|
+
`shadow-${upper(v.SHADOW, "NONE").toLowerCase()}`,
|
|
438
|
+
],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
case "CARD_IMAGE": {
|
|
443
|
+
const ratio = upper(v.RATIO, "SQUARE");
|
|
444
|
+
const fixed = ratio === "FIXED";
|
|
445
|
+
const size = sizeOf(v, "WIDTH");
|
|
446
|
+
return {
|
|
447
|
+
...base,
|
|
448
|
+
...size,
|
|
449
|
+
// Its own (zero) padding: without it the image inherited the card's
|
|
450
|
+
// --cb-p* and padded itself inside its frame.
|
|
451
|
+
sizeVars: { ...size.sizeVars, ...paddingOf({}) },
|
|
452
|
+
natural: ratio === "NATURAL",
|
|
453
|
+
// Product cards only (PRODUCT_LIST's schema has these fields).
|
|
454
|
+
hoverImage: v.HOVER_IMAGE !== false,
|
|
455
|
+
badges: v.BADGES !== false,
|
|
456
|
+
badgePosition: upper(v.BADGE_POSITION, "TOP_LEFT").toLowerCase().replace("_", "-"),
|
|
457
|
+
saleLabel: t(v.SALE_LABEL) || "Sale",
|
|
458
|
+
soldOutLabel: t(v.SOLD_OUT_LABEL) || "Sold out",
|
|
459
|
+
wishlist: !!v.WISHLIST,
|
|
460
|
+
badgeBrand: !!v.BADGE_BRAND,
|
|
461
|
+
badgeCategories: !!v.BADGE_CATEGORIES,
|
|
462
|
+
// The brand / category badges' own corner; null: with the others.
|
|
463
|
+
badgeInfoPosition: upper(v.BADGE_INFO_POSITION, "SAME") === "SAME"
|
|
464
|
+
? null
|
|
465
|
+
: upper(v.BADGE_INFO_POSITION, "SAME").toLowerCase().replace("_", "-"),
|
|
466
|
+
// The wishlist button's look (WISHLIST_*).
|
|
467
|
+
wish: {
|
|
468
|
+
icon: upper(v.WISHLIST_ICON, "HEART"),
|
|
469
|
+
svg: upper(v.WISHLIST_ICON, "HEART") === "CUSTOM" ? String(v.WISHLIST_ICON_SVG || "").trim() : "",
|
|
470
|
+
position: upper(v.WISHLIST_POSITION, "TOP_RIGHT").toLowerCase().replace("_", "-"),
|
|
471
|
+
plain: upper(v.WISHLIST_STYLE, "CIRCLE") === "PLAIN",
|
|
472
|
+
hover: upper(v.WISHLIST_REVEAL, "ALWAYS") === "HOVER",
|
|
473
|
+
vars: compact({
|
|
474
|
+
"--cb-wish-size": px(v.WISHLIST_SIZE),
|
|
475
|
+
"--cb-wish-icon": px(v.WISHLIST_ICON_SIZE),
|
|
476
|
+
"--cb-wish-offset": px(v.WISHLIST_OFFSET),
|
|
477
|
+
"--cb-wish-bg": v.WISHLIST_BG,
|
|
478
|
+
"--cb-wish-border": v.WISHLIST_BORDER_COLOR,
|
|
479
|
+
"--cb-wish-color": v.WISHLIST_COLOR,
|
|
480
|
+
"--cb-wish-active": v.WISHLIST_ACTIVE_COLOR,
|
|
481
|
+
}),
|
|
482
|
+
},
|
|
483
|
+
// The badge's own look (BADGE_* / SALE_BADGE_*); empty = the scheme's.
|
|
484
|
+
badgeVars: compact({
|
|
485
|
+
"--cb-badge-bg": v.BADGE_BG,
|
|
486
|
+
"--cb-badge-color": v.BADGE_TEXT_COLOR,
|
|
487
|
+
"--cb-badge-border": v.BADGE_BORDER_COLOR,
|
|
488
|
+
"--cb-badge-bw": px(v.BADGE_BORDER_WIDTH),
|
|
489
|
+
"--cb-badge-r": px(v.BADGE_RADIUS),
|
|
490
|
+
"--cb-badge-fs": px(v.BADGE_FONT_SIZE),
|
|
491
|
+
"--cb-badge-tt": has(v.BADGE_CASE) ? (upper(v.BADGE_CASE, "UPPERCASE") === "NONE" ? "none" : "uppercase") : undefined,
|
|
492
|
+
"--cb-badge-sale-bg": v.SALE_BADGE_BG,
|
|
493
|
+
"--cb-badge-sale-color": v.SALE_BADGE_TEXT_COLOR,
|
|
494
|
+
"--cb-badge-sale-border": v.SALE_BADGE_BORDER_COLOR,
|
|
495
|
+
"--cb-badge-out-bg": v.SOLD_OUT_BADGE_BG,
|
|
496
|
+
"--cb-badge-out-color": v.SOLD_OUT_BADGE_TEXT_COLOR,
|
|
497
|
+
"--cb-badge-out-border": v.SOLD_OUT_BADGE_BORDER_COLOR,
|
|
498
|
+
}),
|
|
499
|
+
frame: compact({
|
|
500
|
+
"--cb-ratio": fixed || ratio === "NATURAL" ? "auto" : CARD_RATIOS[ratio] ?? "1 / 1",
|
|
501
|
+
"--cb-h": fixed ? px(num(v.IMAGE_HEIGHT, 320)) : undefined,
|
|
502
|
+
"--cb-h-m": fixed ? px(num(v.IMAGE_HEIGHT_MOBILE, num(v.IMAGE_HEIGHT, 320))) : undefined,
|
|
503
|
+
"--cb-fit": upper(v.FIT, "COVER") === "CONTAIN" ? "contain" : "cover",
|
|
504
|
+
borderRadius: px(v.RADIUS),
|
|
505
|
+
}),
|
|
506
|
+
overlay:
|
|
507
|
+
num(v.OVERLAY_OPACITY, 0) > 0
|
|
508
|
+
? { background: v.OVERLAY_COLOR || "#000", opacity: num(v.OVERLAY_OPACITY, 0) / 100 }
|
|
509
|
+
: null,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
case "CARD_TITLE":
|
|
514
|
+
case "CARD_COUNT":
|
|
515
|
+
case "CARD_DESCRIPTION":
|
|
516
|
+
return {
|
|
517
|
+
...base,
|
|
518
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
519
|
+
box: boxOf(v),
|
|
520
|
+
...textLook(v, { CARD_TITLE: "H4", CARD_COUNT: "CUSTOM", CARD_DESCRIPTION: "PARAGRAPH" }[block.type]),
|
|
521
|
+
part: { CARD_TITLE: "title", CARD_COUNT: "count", CARD_DESCRIPTION: "description" }[block.type],
|
|
522
|
+
prefix: t(v.PREFIX),
|
|
523
|
+
singular: t(v.SINGULAR) || "product",
|
|
524
|
+
plural: t(v.PLURAL) || "products",
|
|
525
|
+
lines: num(v.LINES, 2),
|
|
526
|
+
// Product titles clamp only when the block sets LINES.
|
|
527
|
+
clamp: block.type === "CARD_DESCRIPTION" || has(v.LINES),
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
case "CARD_PRICE":
|
|
531
|
+
return {
|
|
532
|
+
...base,
|
|
533
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
534
|
+
box: boxOf(v),
|
|
535
|
+
...textLook(v, "CUSTOM"),
|
|
536
|
+
part: "price",
|
|
537
|
+
showCompare: v.SHOW_COMPARE !== false,
|
|
538
|
+
priceVars: compact({ "--cb-sale": v.SALE_COLOR, "--cb-was": v.COMPARE_COLOR }),
|
|
539
|
+
};
|
|
540
|
+
|
|
541
|
+
case "CARD_SWATCHES":
|
|
542
|
+
return {
|
|
543
|
+
...base,
|
|
544
|
+
box: boxOf(v, { background: false }),
|
|
545
|
+
max: num(v.MAX, 5),
|
|
546
|
+
square: upper(v.SHAPE, "CIRCLE") === "SQUARE",
|
|
547
|
+
// COLOR: the colour option only; ALL: every option (sizes as text).
|
|
548
|
+
show: upper(v.SHOW, "ALL"),
|
|
549
|
+
swatchVars: { "--cb-sw": `${num(v.SIZE, 16)}px`, "--cb-sw-gap": `${num(v.GAP, 6)}px` },
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
case "CARD_BUTTON":
|
|
553
|
+
return {
|
|
554
|
+
...base,
|
|
555
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
556
|
+
box: boxOf(v, { background: false }),
|
|
557
|
+
label: t(v.LABEL) || (has(v.ACTION) ? "Add to cart" : "Shop now"),
|
|
558
|
+
// Product cards: QUICK_BUY | ADD_TO_CART | LINK. Classification cards have no
|
|
559
|
+
// ACTION — their button is only a look.
|
|
560
|
+
action: upper(v.ACTION, "LINK"),
|
|
561
|
+
optionsLabel: t(v.OPTIONS_LABEL) || "Choose options",
|
|
562
|
+
soldOutLabel: t(v.SOLD_OUT_LABEL) || "Sold out",
|
|
563
|
+
quickBuyStyle: upper(v.QUICK_BUY_STYLE, "SHEET"),
|
|
564
|
+
look: upper(v.LOOK, "TEXT"),
|
|
565
|
+
onImage: upper(v.PLACEMENT, "IN_CARD") === "ON_IMAGE",
|
|
566
|
+
// null: same as desktop.
|
|
567
|
+
onImageMobile: { IN_CARD: false, ON_IMAGE: true }[upper(v.PLACEMENT_MOBILE, "SAME")] ?? null,
|
|
568
|
+
imagePosition: upper(v.IMAGE_POSITION, "BOTTOM").toLowerCase().replace("_", "-"),
|
|
569
|
+
imageOffset: px(v.IMAGE_OFFSET),
|
|
570
|
+
revealOnHover: upper(v.REVEAL, "HOVER") === "HOVER",
|
|
571
|
+
// The icon (ICON looks): a preset or the merchant's SVG, and its sizes.
|
|
572
|
+
icon: upper(v.ICON, "BAG"),
|
|
573
|
+
iconSvg: upper(v.ICON, "BAG") === "CUSTOM" ? String(v.ICON_SVG || "").trim() : "",
|
|
574
|
+
btnVars: compact({ "--cb-btn-icon": px(v.ICON_SIZE), "--cb-btn-size": px(v.CIRCLE_SIZE) }),
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
case "BUTTON": {
|
|
578
|
+
const label = t(v.LABEL);
|
|
579
|
+
if (!label || !v.URL) return null;
|
|
580
|
+
return {
|
|
581
|
+
...base,
|
|
582
|
+
...sizeOf(v, "WIDTH", null, "FIT"),
|
|
583
|
+
box: boxOf(v, { background: false }),
|
|
584
|
+
label,
|
|
585
|
+
url: v.URL,
|
|
586
|
+
newTab: !!v.OPEN_IN_NEW_TAB,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
case "ICON":
|
|
591
|
+
return {
|
|
592
|
+
...base,
|
|
593
|
+
box: boxOf(v),
|
|
594
|
+
iconVars: compact({ "--cb-icon": px(v.SIZE), color: v.COLOR }),
|
|
595
|
+
icon: v.ICON_PRESET,
|
|
596
|
+
svg: String(v.ICON_SVG || "").trim(),
|
|
597
|
+
link: v.LINK_URL || null,
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
case "SPACER": {
|
|
601
|
+
const size = upper(v.SIZE, "MEDIUM");
|
|
602
|
+
return {
|
|
603
|
+
...base,
|
|
604
|
+
box: paddingOf({}),
|
|
605
|
+
spacerVars: compact({
|
|
606
|
+
"--cb-sp": `${size === "CUSTOM" ? num(v.SIZE_CUSTOM, 40) : SPACES[size] ?? SPACES.MEDIUM}px`,
|
|
607
|
+
"--cb-sp-m": px(v.SIZE_MOBILE),
|
|
608
|
+
}),
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
case "RATING": {
|
|
613
|
+
const value = Math.max(0, Math.min(5, Number(v.RATING ?? 5) || 0));
|
|
614
|
+
return {
|
|
615
|
+
...base,
|
|
616
|
+
box: boxOf(v),
|
|
617
|
+
value,
|
|
618
|
+
// Each star's filled share, 0..1 — 4.5 → five stars, the last half full.
|
|
619
|
+
stars: [0, 1, 2, 3, 4].map((i) => Math.max(0, Math.min(1, value - i))),
|
|
620
|
+
// A set empty colour is used as is; unset, empty stars are a faint
|
|
621
|
+
// version of the text colour.
|
|
622
|
+
customOff: has(v.EMPTY_COLOR),
|
|
623
|
+
ratingVars: compact({
|
|
624
|
+
"--cb-star": px(num(v.SIZE, 18)),
|
|
625
|
+
"--cb-star-gap": px(num(v.GAP, 2)),
|
|
626
|
+
"--cb-star-on": v.COLOR || "#f5a623",
|
|
627
|
+
"--cb-star-off": v.EMPTY_COLOR,
|
|
628
|
+
}),
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
case "ACCORDION": {
|
|
633
|
+
if (!children.length && !editing.value) return null;
|
|
634
|
+
const grid = upper(v.LAYOUT, "ACCORDION") === "GRID";
|
|
635
|
+
const tag = { H2: "h2", H3: "h3", H4: "h4", P: "p" }[upper(v.QUESTION_TAG, "H3")] ?? "h3";
|
|
636
|
+
const context = {
|
|
637
|
+
grid,
|
|
638
|
+
tag,
|
|
639
|
+
// Same name = the browser keeps one open at a time, natively.
|
|
640
|
+
name: v.ONE_AT_A_TIME !== false ? `faq-${base.key}` : undefined,
|
|
641
|
+
icon: upper(v.ICON_STYLE, "PLUS_MINUS"),
|
|
642
|
+
iconStart: upper(v.ICON_POSITION, "END") === "START",
|
|
643
|
+
};
|
|
644
|
+
let first = true;
|
|
645
|
+
for (const child of children) {
|
|
646
|
+
if (child.type !== "FAQ_ITEM") continue;
|
|
647
|
+
child.acc = context;
|
|
648
|
+
if (first && v.OPEN_FIRST !== false && !grid) child.open = true;
|
|
649
|
+
first = false;
|
|
650
|
+
}
|
|
651
|
+
const divider = upper(v.DIVIDER, "DIVIDER");
|
|
652
|
+
const typeVars = (prefix, key) =>
|
|
653
|
+
compact({
|
|
654
|
+
[`--faq-${key}-fs`]: px(v[`${prefix}_SIZE`]),
|
|
655
|
+
[`--faq-${key}-fs-m`]: px(v[`${prefix}_SIZE_MOBILE`]),
|
|
656
|
+
[`--faq-${key}-fw`]: Number(v[`${prefix}_WEIGHT`]) || undefined,
|
|
657
|
+
[`--faq-${key}-ff`]: v[`${prefix}_FONT`] === "HEADING" ? "var(--osi-font-heading)" : undefined,
|
|
658
|
+
[`--faq-${key}-lh`]: has(v[`${prefix}_LINE_HEIGHT`]) ? String(num(v[`${prefix}_LINE_HEIGHT`], 150) / 100) : undefined,
|
|
659
|
+
[`--faq-${key}-color`]: v[`${prefix}_COLOR`],
|
|
660
|
+
});
|
|
661
|
+
return {
|
|
662
|
+
...base,
|
|
663
|
+
...sizeOf(v, "WIDTH"),
|
|
664
|
+
box: boxOf(v),
|
|
665
|
+
accClasses: [grid ? "is-grid" : "is-list", `rows-${divider.toLowerCase()}`],
|
|
666
|
+
accVars: {
|
|
667
|
+
"--faq-gap": `${num(v.ITEM_SPACING, 0)}px`,
|
|
668
|
+
"--faq-cols": String(num(v.COLUMNS, 2)),
|
|
669
|
+
"--faq-cols-m": String(num(v.COLUMNS_MOBILE, 1)),
|
|
670
|
+
"--faq-radius": `${num(v.ROW_RADIUS, 8)}px`,
|
|
671
|
+
"--faq-py": `${num(v.ROW_PADDING_Y, 16)}px`,
|
|
672
|
+
"--faq-px": `${num(v.ROW_PADDING_X, 0)}px`,
|
|
673
|
+
"--faq-icon": `${num(v.ICON_SIZE, 12)}px`,
|
|
674
|
+
...compact({
|
|
675
|
+
"--faq-line": v.DIVIDER_COLOR,
|
|
676
|
+
"--faq-row-bg": v.ROW_BG,
|
|
677
|
+
"--faq-icon-color": v.ICON_COLOR,
|
|
678
|
+
}),
|
|
679
|
+
...typeVars("QUESTION", "q"),
|
|
680
|
+
...typeVars("ANSWER", "a"),
|
|
681
|
+
},
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
case "FAQ_ITEM": {
|
|
686
|
+
const question = t(v.QUESTION);
|
|
687
|
+
if (!question && !editing.value) return null;
|
|
688
|
+
return {
|
|
689
|
+
...base,
|
|
690
|
+
box: paddingOf({}),
|
|
691
|
+
question: question || "Question",
|
|
692
|
+
answer: t(v.ANSWER),
|
|
693
|
+
open: v.OPEN === true,
|
|
694
|
+
// Replaced by the accordion it sits in; a lone question uses these.
|
|
695
|
+
acc: { grid: false, tag: "h3", name: undefined, icon: "PLUS_MINUS", iconStart: false },
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
default: {
|
|
700
|
+
// A section's own block types (the footer's Menu, Logo…): the
|
|
701
|
+
// section passes options.custom = { TYPE: Component }. Drawn by that
|
|
702
|
+
// component with the node's box / width and its raw settings.
|
|
703
|
+
// options.raw: types the section draws itself from the tree (the
|
|
704
|
+
// classification page's Toolbar / Product grid) — kept as plain nodes.
|
|
705
|
+
if (options.raw?.includes(block.type)) return base;
|
|
706
|
+
const component = options.custom?.[block.type];
|
|
707
|
+
if (!component) return null;
|
|
708
|
+
// options.customText: { TYPE: default preset } — blocks with the Text
|
|
709
|
+
// block's look (a banner's title…) get it resolved like Text does.
|
|
710
|
+
const textDefault = options.customText?.[block.type];
|
|
711
|
+
return {
|
|
712
|
+
...base,
|
|
713
|
+
...sizeOf(v, "WIDTH", "WIDTH_MOBILE", "FILL"),
|
|
714
|
+
box: boxOf(v, { background: false }),
|
|
715
|
+
...(textDefault ? textLook(v, textDefault) : {}),
|
|
716
|
+
component: markRaw(component),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* VISIBILITY (every content block): shown everywhere, or only on phones /
|
|
724
|
+
* only on larger screens. A class the parent puts on the node's element
|
|
725
|
+
* (ContentNode's `vis-*`), so it's CSS — no flash, works with SSR.
|
|
726
|
+
*/
|
|
727
|
+
const VIS = { DESKTOP: "vis-desktop", MOBILE: "vis-mobile" };
|
|
728
|
+
function withVisibility(block, node) {
|
|
729
|
+
if (!node) return node;
|
|
730
|
+
const vis = VIS[upper(block.settings?.VISIBILITY, "ALL")];
|
|
731
|
+
return vis ? { ...node, visClass: vis } : node;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
const tree = computed(() => {
|
|
735
|
+
const blocks = toValue(section)?.blocks ?? [];
|
|
736
|
+
const keys = new Set(blocks.map((b) => b.key));
|
|
737
|
+
const byParent = new Map();
|
|
738
|
+
for (const b of blocks) {
|
|
739
|
+
const parent = b.parent_key || "";
|
|
740
|
+
// A parent that isn't here (a hidden group the API left out) takes its
|
|
741
|
+
// children with it — lifting them to the top would scramble the layout.
|
|
742
|
+
if (parent && !keys.has(parent)) continue;
|
|
743
|
+
if (!byParent.has(parent)) byParent.set(parent, []);
|
|
744
|
+
byParent.get(parent).push(b);
|
|
745
|
+
}
|
|
746
|
+
firstImage = true;
|
|
747
|
+
const build = (parent, depth) =>
|
|
748
|
+
depth > 8
|
|
749
|
+
? []
|
|
750
|
+
: (byParent.get(parent) ?? [])
|
|
751
|
+
.filter((b) => !b.disabled)
|
|
752
|
+
.map((b) => withVisibility(b, resolve(b, CONTAINERS.has(b.type) ? build(b.key, depth + 1) : [], parent === "")))
|
|
753
|
+
.filter(Boolean);
|
|
754
|
+
const children = build("", 0);
|
|
755
|
+
return {
|
|
756
|
+
key: "root",
|
|
757
|
+
type: "GROUP",
|
|
758
|
+
children,
|
|
759
|
+
...flexOf({ ...(options.rootDefaults ?? {}), ...s.value }),
|
|
760
|
+
sizeClasses: [],
|
|
761
|
+
sizeVars: {},
|
|
762
|
+
box: paddingOf({}),
|
|
763
|
+
bg: null,
|
|
764
|
+
overlay: null,
|
|
765
|
+
link: null,
|
|
766
|
+
};
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
return { tree, backgroundOf, editing, u: { has, num, px, compact, upper } };
|
|
770
|
+
}
|